Mengatasi Permasalahan N+1 Pada Query Di Hibernate


Apa itu permasalahan N+1? Sebagai contoh, anggap saja saya memiliki sebuah JPA entity seperti berikut ini:

@NamedEntityGraphs([
    @NamedEntityGraph(name='FakturJualOlehSales.Piutang', attributeNodes=[
        @NamedAttributeNode('listItemFaktur'),
        @NamedAttributeNode('piutang')
    ])
])
class FakturJualOlehSales extends FakturJual {

    @NotNull(groups=[Default,InputPenjualanOlehSales]) @ManyToOne
    Konsumen konsumen

    @NotNull(groups=[Default]) @Type(type="org.jadira.usertype.dateandtime.joda.PersistentLocalDate")
    LocalDate jatuhTempo

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.LAZY)
    KewajibanPembayaran piutang

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.LAZY)
    BonusPenjualan bonusPenjualan

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true, fetch=FetchType.EAGER)
    @JoinTable(name='FakturJual_retur')
    @OrderColumn
    List<PenerimaanBarang> retur = []

    ...

}

Class FakturJualOlehSales memiliki banyak relasi dengan class lainnya. Ini adalah sesuatu yang wajar terutama bila perancangan dilakukan dengan menggunakan metode DDD yang mengedepankan aggregation. Sebagai turunan dari FakturJual, class FakturJualOlehSales memiliki relasi one-to-many dengan ItemFaktur. Class FakturJualOlehSales juga memiliki relasi one-to-one dengan KewajibanPembayaran (untuk mewakili piutang yang ditimbulkan oleh faktur ini) dan BonusPenjualan (untuk mewakili bonus yang diberikan pada faktur ini). Class KewajibanPembayaran selanjutnya memiliki relasi one-to-many dengan Pembayaran. Begitu juga, class BonusPenjualan selanjutnya memiliki relasi one-to-many dengan ItemBarang. Selain itu, class FakturJualOlehSales juga memiliki relasi one-to-many dengan PenerimaanBarang untuk mewakili retur.

Sebuah class dengan relasi yang kompleks, bukan? Tapi saya tidak selalu butuh semua nilai relasi yang ada setiap kali berhadapan dengan FakturJualOlehSales. Sebagai contoh, pada screen untuk entry data faktur, saya tidak perlu menampilkan nilai piutang. Tetapi pada screen untuk memasukkan data pembayaran piutang, saya perlu nilai piutang tetapi tidak perlu informasi seperti bonusPenjualan.

Oleh sebab itu, saya memberikan nilai atribut fetch=FetchType.LAZY pada beberapa atribut agar Hibernate tidak men-query atribut tersebut. Teknik ini disebut lazy loading. Nilai dari atribut piutang atau bonusPenjualan hanya akan di-query pada saat ia diakses. Ini hanya berlaku selama entity masih berada dalam pengelolaan EntityManager. Bila sudah diluar cakupan EntityManager, saya akan memperoleh pesan kesalahan LazyLoadingException yang sangat terkenal.

Cara lain untuk membaca nilai yang lazy adalah dengan menggunakan query JP QL yang melakukan join fetch secara manual. Khusus untuk JPA 2.1, pilihan yang lebih nyaman adalah dengan menggunakan fasilitas named entity graph. Dengan fasilitas ini, saya tidak perlu menghabiskan banyak waktu memikirkan query! Sebagai contoh, saya mendeklarasikan sebuah named entity graph dengan nama FakturJualOlehSales.Piutang yang akan menyertakan nilai atribut piutang pada saat FakturJualOlehSales dibaca dari database.

Berikut adalah contoh kode program yang memakai named entity graph melalui simple-jpa:

FakturJualRepository repo = simplejpa.SimpleJpaUtil.instance
    .repositoryManager.findRepository('FakturJual')

int start = System.currentTimeMillis();
repo.findAllFakturJualOlehSalesFetchPiutang();
int stop = System.currentTimeMillis();

println "Delta = ${stop-start}"

Walaupun kode program di atas terlihat sederhana, tapi kinerjanya tidak memuaskan! Hibernate ternyata mengerjakan sangat banyak query, seperti:

select distinct ... from FakturJual fakturjual0_ left outer join KewajibanPembayaran kewajibanp1_ on fakturjual0_.piutang_id=kewajibanp1_.id left outer join FakturJual_listItemFaktur listitemfa2_ on fakturjual0_.id=listitemfa2_.FakturJual_id where ...

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

select ... from Produk produk0_ inner join Satuan satuan1_ on produk0_.satuan_id=satuan1_.id left outer join Supplier supplier2_ on produk0_.supplier_id=supplier2_.id where produk0_.id=?

...

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

select ... from Konsumen konsumen0_ inner join Region region1_ on konsumen0_.region_id=region1_.id left outer join Region region2_ on region1_.bagianDari_id=region2_.id inner join Sales sales3_ on konsumen0_.sales_id=sales3_.id inner join Gudang gudang4_ on sales3_.gudang_id=gudang4_.id where konsumen0_.id=?

...

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

...

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from kewajibanpembayaran_items listpembay0_ left outer join BilyetGiro bilyetgiro1_ on listpembay0_.bilyetGiro_id=bilyetgiro1_.id where listpembay0_.KewajibanPembayaran_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

Permasalahan ini sering kali disebut sebagai permasalahan N+1. Nilai 1 adalah query pertama untuk SELECT * FROM x. Setelah itu, untuk N jumlah record yang diperoleh dari query pertama, lakukan query lain untuk membaca nilai di tabel lain seperti SELECT * FROM y WHERE y.x_id = x.id. Dengan demikian, semakin banyak jumlah record yang hendak dibaca, semakin banyak juga query tambahan yang perlu dilakukan. Permasalahan N+1 biasanya adalah pemborosan kinerja yang tak seharusnya terjadi karena ia dapat digantikan dengan join dan/atau subquery.

Sebagai patokan, saya akan menyimpan hasil eksekusi program di atas dan membuat versi grafik-nya yang terlihat seperti pada gambar berikut ini:

Grafik yang menunjukkan kinerja awal

Grafik yang menunjukkan kinerja awal

Pada grafik di atas, pada eksekusi pertama kali, saya akan memperoleh penalti kinerja yang cukup tinggi. Ini adalah karakteristik dari simple-jpa. Selain itu, hal ini juga ditambah lagi dengan server database yang belum memiliki cache hasil query.

Saya akan mulai dengan berusaha menghilangkan query N+1 ke tabel konsumen. FakturJualOlehSales memiliki hubungan @ManyToOne dengan konsumen. Saya menemukan bahwa dengan menyertakan definisi atribut konsumen pada named entity graph, maka query N+1 untuk relasi ke konsumen tidak akan muncul lagi. Perubahan yang saya lakukan menyebabkan definisi named entity graph saya menjadi seperti berikut ini:

@NamedEntityGraph(name='FakturJualOlehSales.Piutang', attributeNodes=[
    @NamedAttributeNode('listItemFaktur'),
    @NamedAttributeNode(value='konsumen', subgraph='konsumen'),
    @NamedAttributeNode('piutang')
], subgraphs = [
    @NamedSubgraph(name='konsumen', attributeNodes=[
        @NamedAttributeNode('region'),
        @NamedAttributeNode('sales')
    ])
])

Sekarang, nilai untuk konsumen tidak akan di-query satu per satu lagi, melainkan diperoleh melalui join pada saat mengambil nilai FakturJualOlehSales seperti yang terlihat pada SQL yang dihasilkan oleh Hiberate:

select distinct ... from FakturJual fakturjual0_ ... left outer join Konsumen konsumen2_ on fakturjual0_.konsumen_id=konsumen2_.id left outer join Region region3_ on konsumen2_.region_id=region3_.id left outer join Sales sales4_ on konsumen2_.sales_id=sales4_.id ...

Sayang sekali saya tidak dapat melakukan hal yang sama untuk Produk karena listItemFaktur adalah sebuah @ElementCollection yang tidak dianggap sebuah entity sehingga tidak dapat diatur melalui named entity graph.

Sampai disini, apakah versi yang memakai left outer join akan lebih cepat dari versi N+1? Saya akan kembali melakukan sedikit percobaan dan menemukan hasil seperti yang terlihat pada gambar berikut ini:

Grafik yang menunjukkan kinerja setelah perubahan.

Grafik yang menunjukkan kinerja setelah perubahan.

Pada grafik di atas, terlihat bahwa perubahan yang saya lakukan memberikan sedikit peningkatan kinerja (sekitar 8%). Hal ini karena pada rancangan saya, tidak banyak yang bisa dioptimalkan dengan cara seperti ini.

Sebagai langkah berikutnya, saya akan menghindari query N+1 untuk retur dengan menjadikannya sebagai 1 query tunggal yang terpisah. Saya dapat menggunakan @Fetch(FetchMode.SUBSELECT) untuk keperluan seperti ini. Sebagai informasi, @Fetch adalah annotation khusus dari Hibernate dan bukan merupakan bagian dari JPA! Sebagai contoh, saya mengubah kode program menjadi seperti berikut ini:

class FakturJualOlehSales extends FakturJual {

   ...

   @OneToMany(...) @JoinTable()
   @Fetch(FetchMode.SUBSELECT)
   List<PenerimaanBarang> retur = []

   ...

}

Konfigurasi di atas akan menyebabkan seluruh query N+1 yang tadinya seperti:

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id=?

...

digantikan oleh sebuah query tunggal yang isinya seperti berikut ini:

select ... from FakturJual_retur retur0_ inner join PenerimaanBarang penerimaan1_ on retur0_.retur_id=penerimaan1_.id inner join Gudang gudang2_ on penerimaan1_.gudang_id=gudang2_.id where retur0_.FakturJual_id in (select fakturjual0_.id from FakturJual fakturjual0_ ...)

Saya segera menambahkan @Fetch(FetchMode.SUBSELECT) pada beberapa atribut lainnya yang memiliki permasalahan N+1. Setelah itu, saya mencoba menjalankan program dan kini memperoleh kinerja seperti yang terlihat pada grafis berikut ini:

Grafik yang menunjukkan kinerja setelah perubahan.

Grafik yang menunjukkan kinerja setelah perubahan.

Kali ini saya memperoleh peningkatan kinerja yang cukup drastis karena saya menemukan banyak atribut yang bisa dioptimalkan melalui @Fetch(FetchMode.SUBSELECT). Sebagai hasil akhir, setelah berupaya menghilangkan sebagian besar query N+1, saya memperoleh peningkatan kinerja sebesar 56%. Tidak ada perubahan yang perlu saya lakukan pada kode program yang membaca entitas; ia tetap merupakan sebuah baris yang polos seperti findAllFakturJualFetchPiutang().

Perihal Solid Snake
I'm nothing...

Apa komentar Anda?

Please log in using one of these methods to post your comment:

Logo WordPress.com

You are commenting using your WordPress.com account. Logout / Ubah )

Gambar Twitter

You are commenting using your Twitter account. Logout / Ubah )

Foto Facebook

You are commenting using your Facebook account. Logout / Ubah )

Foto Google+

You are commenting using your Google+ account. Logout / Ubah )

Connecting to %s

%d blogger menyukai ini: