Anak bayi datang ke ayahnya
Dan bertanya pada bayi itu
- Apa yang baik?
dan apa yang buruk
Vladimir Mayakovsky
Artikel ini adalah tentang Spring Data JPA, yaitu di rake bawah air yang saya temui dalam perjalanan, dan tentu saja sedikit tentang kinerja.
Contoh yang dijelaskan dalam artikel dapat dijalankan di lingkungan pengujian, dapat diakses dengan referensi .
Catatan untuk mereka yang belum pindah ke Spring Boot 2Dalam versi Spring Data JPA 2. * antarmuka utama untuk bekerja dengan repositori, yaitu CrudRepository
, dari mana JpaRepository
diwarisi, telah JpaRepository
. Dalam versi 1. * metode utama tampak seperti ini:
public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); }
Dalam versi baru:
public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); }
Jadi mari kita mulai.
pilih t. * dari mana t.id di (...)
Salah satu pertanyaan paling umum adalah kueri dari formulir "pilih semua catatan yang kuncinya jatuh ke dalam set yang dikirimkan". Saya yakin hampir semua dari Anda menulis atau melihat sesuatu seperti
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") List<Long> ids); @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
Ini berfungsi, permintaan yang sesuai, tidak ada masalah tangkapan atau kinerja, tetapi ada kelemahan kecil, benar-benar tidak mencolok.
Sebelum Anda membuka liner, cobalah untuk berpikir sendiri.Kerugiannya adalah antarmuka terlalu sempit untuk mengirim kunci. "Jadi apa?" - katamu. "Yah daftarnya, well set, aku tidak melihat masalah di sini." Namun, jika kita melihat metode antarmuka root yang mengambil banyak nilai, maka di mana pun kita melihat Iterable
:
"Jadi apa? Dan aku ingin daftar. Kenapa lebih buruk?"
Tidak lebih buruk, bersiaplah untuk penampilan kode serupa di tingkat yang lebih tinggi dalam aplikasi Anda:
public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); }
Kode ini tidak melakukan apa pun kecuali membalikkan koleksi. Mungkin terjadi bahwa argumen ke metode adalah daftar, dan metode repositori menerima set (atau sebaliknya), dan Anda hanya perlu memesan ulang untuk melewati kompilasi. Tentu saja, ini tidak akan menjadi masalah dengan latar belakang biaya overhead untuk permintaan itu sendiri, ini lebih tentang gerakan yang tidak perlu.
Oleh karena itu, praktik yang baik untuk menggunakan Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
Z.Y. Jika kita berbicara tentang metode dari *RepositoryCustom
, maka masuk akal untuk menggunakan Collection
untuk menyederhanakan perhitungan ukuran di dalam implementasi:
public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds); } public class BankAccountRepositoryImpl { @Override public boolean anyMoneyAvailable(Collection<Long> accountIds) { if (ids.isEmpty()) return false;
Kode tambahan: kunci non-duplikat
Sebagai kelanjutan dari bagian terakhir, saya ingin menarik perhatian pada kesalahpahaman umum:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
Manifestasi lain dari kesalahan yang sama:
Set<Long> ids = new HashSet<>(notUniqueIds); List<BankAccount> accounts = repository.findByUserIds(ids); List<Long> ids = ts.stream().map(T::id).distinct().collect(toList()); List<BankAccount> accounts = repository.findByUserIds(ids); Set<Long> ids = ts.stream().map(T::id).collect(toSet()); List<BankAccount> accounts = repository.findByUserIds(ids);
Sepintas, tidak ada yang aneh, bukan?
Luangkan waktu Anda, pikirkan sendiri;)HQL / JPQL kueri dari formulir select t from t where t.field in ...
akhirnya akan berubah menjadi kueri
select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …)
yang akan selalu mengembalikan hal yang sama terlepas dari adanya pengulangan dalam argumen. Karena itu, untuk memastikan keunikan kunci tidak diperlukan. Ada satu kasus khusus - Oracle, di mana menekan> 1000 kunci menyebabkan kesalahan. Tetapi jika Anda mencoba untuk mengurangi jumlah kunci dengan mengecualikan pengulangan, maka Anda harus lebih memikirkan alasan terjadinya mereka. Kemungkinan besar kesalahan ada di suatu tempat di atas.
Jadi dalam penggunaan kode yang baik Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
Samopis
Perhatikan kode ini dari dekat dan temukan di sini tiga kelemahan dan satu kemungkinan kesalahan:
@Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids);
Pikirkan lagi- semuanya sudah diimplementasikan di
SimpleJpaRepository::findAllById
- permintaan idle saat melewati daftar kosong (di
SimpleJpaRepository::findAllById
ada centang yang sesuai) - semua pertanyaan yang dijelaskan menggunakan
@Query
diperiksa pada tahap meningkatkan konteks, yang membutuhkan waktu (tidak seperti SimpleJpaRepository::findAllById
) - jika Oracle digunakan, ketika kumpulan kunci kosong, kami mendapatkan kesalahan
ORA-00936: missing expression
(yang tidak akan terjadi ketika menggunakan SimpleJpaRepository::findAllById
, lihat poin 2)
Harry potter dan kunci majemuk
Lihatlah dua contoh dan pilih yang Anda sukai:
Metode jumlah kali
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; }
Metode nomor dua
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; }
Sepintas tidak ada perbedaan. Sekarang coba metode pertama dan jalankan tes sederhana:
Di log kueri (Anda menyimpannya, kan?) Kita akan melihat ini:
select e.key1, e.key2 from CompositeKeyEntity e where e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ?
Sekarang contoh kedua
Log kueri terlihat berbeda:
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
Itulah perbedaan keseluruhan: dalam kasus pertama kami selalu mendapatkan 1 permintaan, dalam yang kedua - n permintaan.
Alasan untuk perilaku ini terletak pada SimpleJpaRepository::findAllById
:
Metode mana yang terbaik bagi Anda untuk menentukan berdasarkan seberapa penting jumlah permintaan.
Extra CrudRepository :: save
Seringkali dalam kode ada antipattern seperti:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); }
Pembaca bingung: di mana antipattern? Kode ini hanya terlihat sangat logis: kita mendapatkan entitas - perbarui - simpan. Semuanya seperti di rumah-rumah terbaik di St. Petersburg. Saya berani mengatakan bahwa memanggil CrudRepository::save
tidak perlu di sini.
Pertama: metode updateRate
transaksional, oleh karena itu, semua perubahan dalam entitas yang dikelola dimonitor oleh Hibernate dan berubah menjadi permintaan ketika Session::flush
dieksekusi, yang dalam kode ini terjadi ketika metode berakhir.
Kedua, CrudRepository::save
lihat metode CrudRepository::save
. Seperti yang Anda ketahui, semua repositori didasarkan pada SimpleJpaRepository
. Berikut ini adalah implementasi dari CrudRepository::save
:
@Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } }
Ada kehalusan yang tidak semua orang ingat: Hibernate berfungsi melalui peristiwa. Dengan kata lain, setiap tindakan pengguna menghasilkan acara yang antri dan diproses dengan mempertimbangkan peristiwa lainnya dalam antrian yang sama. Dalam kasus ini, panggilan ke EntityManager::merge
menghasilkan MergeEvent
, yang diproses secara default dalam metode DefaultMergeEventListener::onMerge
. Ini berisi logika yang cukup bercabang, tetapi sederhana untuk masing-masing negara dari entitas-argumen. Dalam kasus kami, entitas diperoleh dari repositori di dalam metode transaksional dan berada dalam status PERSISTEN (mis., Pada dasarnya dikendalikan oleh kerangka kerja):
protected void entityIsPersistent(MergeEvent event, Map copyCache) { LOG.trace("Ignoring persistent instance"); Object entity = event.getEntity(); EventSource source = event.getSession(); EntityPersister persister = source.getEntityPersister(event.getEntityName(), entity); ((MergeContext)copyCache).put(entity, entity, true); this.cascadeOnMerge(source, persister, entity, copyCache);
Iblis ada dalam perinciannya, yaitu dalam metode DefaultMergeEventListener::cascadeOnMerge
dan DefaultMergeEventListener::copyValues
. Mari kita dengarkan pidato langsung Vlad Mikhalche , salah satu pengembang utama Hibernate:
Dalam panggilan metode copyValues, keadaan terhidrasi disalin lagi, sehingga array baru dibuat secara berlebihan, sehingga membuang siklus CPU. Jika entitas memiliki asosiasi anak dan operasi penggabungan juga mengalir dari entitas induk ke entitas anak, biaya overhead bahkan lebih besar karena setiap entitas anak akan menyebarkan MergeEvent dan siklus berlanjut.
Dengan kata lain, pekerjaan sedang dilakukan yang tidak dapat Anda lakukan. Akibatnya, kode kami dapat disederhanakan sambil meningkatkan kinerjanya:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; }
Tentu saja, tidak nyaman untuk mengingat hal ini ketika mengembangkan dan mengoreksi kode orang lain, jadi kami ingin membuat perubahan pada tingkat gambar rangka sehingga metode JpaRepository::save
kehilangan properti berbahaya. Apakah ini mungkin?
Namun, pembaca yang canggih mungkin sudah merasakan ada sesuatu yang salah. Memang, perubahan ini tidak akan merusak apa pun, tetapi hanya dalam kasus sederhana ketika tidak ada entitas anak:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; }
Sekarang anggap bahwa pemiliknya terikat pada akun:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; }
Ada metode yang memungkinkan Anda memutuskan koneksi pengguna dari akun dan mentransfer yang terakhir ke pengguna baru:
@Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); }
Apa yang akan terjadi sekarang? Memeriksa em.contains(entity)
akan mengembalikan true, yang berarti em.merge(entity)
tidak akan dipanggil. Jika kunci entitas User
dibuat berdasarkan urutan (salah satu kasus paling umum), maka itu tidak akan dibuat sampai transaksi selesai (atau Session::flush
disebut secara manual), yaitu pengguna akan berada dalam status DETACHED, dan entitas induknya ( akun) - dalam status PERSISTEN. Dalam beberapa kasus, ini dapat mematahkan logika aplikasi, yang terjadi:
02/03/2018 DATAJPA-931 berhenti bergabung dengan RepositoryItemWriter
Dalam hal ini, tugas Kembalikan optimasi yang dibuat untuk entitas yang ada di CrudRepository :: save dibuat dan perubahan dibuat: Kembalikan DATAJPA-931 .
Blind CrudRepository :: findById
Kami terus mempertimbangkan model data yang sama:
@Entity public class User { @Id Long id;
Aplikasi memiliki metode yang membuat akun baru untuk pengguna yang ditentukan:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser);
Dengan versi 2. * antipattern yang ditunjukkan oleh panah tidak begitu mencolok - lebih jelas terlihat pada versi yang lebih lama:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId));
Jika Anda tidak melihat cacat \ "dengan mata \", maka lihatlah kueri: select u.id, u.name from user u where u.id = ? call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
Permintaan pertama kami dapatkan pengguna dengan kunci. Selanjutnya, kita mendapatkan kunci untuk akun baru lahir dari database dan memasukkannya ke dalam tabel. Dan satu-satunya hal yang kita ambil dari pengguna adalah kuncinya, yang sudah kita miliki sebagai argumen metode. Di sisi lain, BankAccount
berisi bidang "pengguna" dan kami tidak dapat membiarkannya kosong (karena orang yang baik kami menetapkan batasan dalam skema). Pengembang berpengalaman mungkin sudah melihat jalannya dan makan ikan, dan naik kuda dapatkan pengguna dan permintaan untuk tidak:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId));
JpaRepository::getOne
mengembalikan pembungkus atas kunci yang memiliki tipe yang sama dengan "entitas" yang hidup. Kode ini hanya memberikan dua permintaan:
call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
Saat entitas yang dibuat berisi banyak bidang dengan hubungan banyak-ke-satu / satu-ke-satu, teknik ini akan membantu mempercepat penyimpanan dan mengurangi beban pada basis data.
Menjalankan Query HQL
Ini adalah topik yang terpisah dan menarik :). Model domainnya sama dan ada permintaan seperti itu:
@Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id);
Pertimbangkan HQL "murni":
select count(ba) from BankAccount ba join ba.user user where user.id = :id
Ketika dijalankan, query SQL berikut akan dibuat:
select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ?
Masalahnya di sini tidak segera terbukti bahkan oleh kehidupan yang bijaksana dan pengembang SQL yang dipahami dengan baik: inner join
dengan kunci pengguna akan mengecualikan akun dengan user_id
hilang dari sampel (dan dengan cara yang baik, memasukkan itu harus dilarang di tingkat skema), yang berarti bahwa tidak user_id
bergabung dengan tabel user
sama sekali perlu. Permintaan dapat disederhanakan (dan dipercepat):
select count(ba.id) from bank_account ba where ba.user_id = ?
Ada cara untuk dengan mudah mencapai perilaku ini di c menggunakan HQL:
@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id);
Metode ini menciptakan permintaan "lite".
Abstrak kueri vs. metode
Salah satu fitur utama dari Spring Data adalah kemampuan untuk membuat kueri dari nama metode, yang sangat nyaman, terutama dalam kombinasi dengan add-on cerdas dari IntelliJ IDEA. Permintaan yang dijelaskan dalam contoh sebelumnya dapat dengan mudah ditulis ulang:
Tampaknya lebih sederhana, dan lebih pendek, dan lebih mudah dibaca, dan yang paling penting - Anda tidak perlu melihat permintaan itu sendiri. Saya membaca nama metode - dan sudah jelas apa yang dipilih dan bagaimana caranya. Tetapi iblis ada di sini dalam perinciannya. Query terakhir untuk metode yang ditandai dengan @Query
telah kita lihat. Apa yang akan terjadi pada kasus kedua?
Babah! select count(ba.id) from bank_account ba left outer join // <
"Apa-apaan ini !?" - pengembang akan berseru. Bagaimanapun, kita sudah melihat itu pemain biola join
tidak diperlukan.
Alasannya biasa-biasa saja:
Jika Anda belum memutakhirkan ke versi yang ditambal, dan bergabung dengan tabel memperlambat kueri di sini dan sekarang, maka jangan putus asa: ada dua cara untuk mengurangi rasa sakit:
cara yang baik adalah menambahkan optional = false
(jika sirkuit memungkinkan):
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; }
Cara kruk adalah menambahkan kolom dengan tipe yang sama dengan kunci Entitas pengguna dan menggunakannya dalam kueri alih-alih bidang user
:
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; @Column(name = "user_id", insertable = false, updatable = false) Long userId; }
Sekarang permintaan-dari-metode akan lebih baik:
long countByUserId(Long id);
memberi
select count(ba.id) from bank_account ba where ba.user_id = ?
apa yang kita capai.
Batas pengambilan sampel
Untuk keperluan kami, kami perlu membatasi pemilihan (misalnya, kami ingin mengembalikan Optional
dari metode *RepositoryCustom
):
select ba.* from bank_account ba order by ba.rate limit ?
Sekarang Jawa:
@Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; BankAccount account = em .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .getSingleResult(); return Optional.ofNullable(bankAccount); }
Kode yang ditentukan memiliki satu fitur yang tidak menyenangkan: jika permintaan mengembalikan pilihan kosong, pengecualian akan dilemparkan
Caused by: javax.persistence.NoResultException: No entity found for query
Dalam proyek yang saya lihat, ini diselesaikan dengan dua cara utama:
- coba-tangkap dengan variasi mulai dari
Optonal.empty()
pengecualian dan mengembalikan Optonal.empty()
ke cara yang lebih maju, seperti mengoper lambda dengan permintaan ke metode utilitas - aspek di mana metode repositori dibungkus kembali
Optional
Dan sangat jarang, saya melihat solusi yang tepat:
@Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; return em.unwrap(Session.class) .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .uniqueResultOptional(); }
EntityManager
adalah bagian dari standar JPA, sementara Session
adalah milik Hibernate dan merupakan alat IMHO yang lebih canggih, yang sering dilupakan.
[Terkadang] perbaikan yang berbahaya
Saat Anda perlu mendapatkan satu bidang kecil dari entitas "tebal", kami melakukan ini:
@Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id);
Permintaan memungkinkan Anda untuk mendapatkan satu bidang tipe boolean
tanpa memuat seluruh entitas (dengan penambahan cache tingkat pertama, memeriksa perubahan di akhir sesi, dan biaya lainnya). Kadang-kadang ini tidak hanya tidak meningkatkan kinerja, tetapi sebaliknya - itu menciptakan pertanyaan yang tidak perlu dari awal. Bayangkan sebuah kode yang melakukan beberapa pemeriksaan:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
Kode ini membuat setidaknya 2 permintaan, meskipun yang kedua dapat dihindari:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
Kesimpulannya sederhana: jangan mengabaikan cache dari level pertama, dalam kerangka satu transaksi, hanya JpaRepository::findById
pertama JpaRepository::findById
merujuk ke database, JpaRepository::findById
cache dari level pertama selalu aktif dan terikat pada sesi, yang biasanya terkait dengan transaksi saat ini.
Tes untuk dimainkan (tautan ke repositori diberikan di awal artikel):
- tes antarmuka sempit:
InterfaceNarrowingTest
- uji untuk contoh dengan kunci komposit:
EntityWithCompositeKeyRepositoryTest
- uji kelebihan
CrudRepository::save
: ModifierTest.java
- blind test
CrudRepository::findById
: ChildServiceImplTest
- tes
left join
tidak perlu: BankAccountControlRepositoryTest
Biaya panggilan tambahan ke CrudRepository::save
dapat dihitung menggunakan RedundantSaveBenchmark
. Diluncurkan menggunakan kelas BenchmarkRunner
.