Saya dulu takut akan caching. Saya benar-benar tidak ingin memanjat dan mencari tahu apa itu, saya segera membayangkan beberapa kompartemen mesin hal-hal perusahaan-luto yang hanya bisa ditentukan oleh pemenang olimpiade matematika. Ternyata ini tidak benar. Caching ternyata sangat sederhana, dapat dimengerti dan sangat mudah diimplementasikan dalam proyek apa pun.

Dalam posting ini, saya akan mencoba menjelaskan tentang caching sesederhana yang saya mengerti sekarang. Anda akan belajar tentang cara menerapkan caching dalam 1 menit, cara cache dengan kunci, mengatur masa pakai cache, dan banyak hal lain yang perlu Anda ketahui jika Anda diminta untuk menembolok sesuatu di proyek kerja Anda, dan Anda tidak ingin mengacaukan wajah.
Mengapa saya mengatakan "dipercayakan"? Karena caching, sebagai suatu peraturan, masuk akal untuk diterapkan dalam proyek-proyek besar yang sarat muatan, dengan puluhan ribu permintaan per menit. Dalam proyek semacam itu, agar tidak membebani database, mereka biasanya men-cache panggilan repositori. Terutama jika diketahui bahwa data dari beberapa sistem master diperbarui pada frekuensi tertentu. Kami sendiri tidak menulis proyek seperti itu, kami mengerjakannya. Jika proyek ini kecil dan tidak mengancam kelebihan, maka, tentu saja, lebih baik tidak melakukan cache apa pun - selalu data segar selalu lebih baik daripada yang diperbarui secara berkala.
Biasanya, dalam posting pelatihan, pembicara pertama merangkak di bawah tenda, mulai menggali ke dalam keberanian teknologi, yang banyak mengganggu pembaca, dan hanya kemudian, ketika dia membuka-buka setengah bagian artikel yang baik dan tidak mengerti apa-apa, ia memberitahu cara kerjanya. Semuanya akan berbeda dengan kita. Pertama, kami membuatnya bekerja, dan lebih baik, dengan sedikit usaha, dan hanya kemudian, jika Anda tertarik, Anda dapat melihat di bawah kap cache, mencari di dalam bin itu sendiri dan caching fine-tune. Tetapi bahkan jika Anda tidak (dan ini dimulai dengan poin 6), caching Anda akan berfungsi seperti itu.
Kami akan membuat proyek di mana kami akan menganalisis semua aspek caching yang saya janjikan. Pada akhirnya, seperti biasa, akan ada tautan ke proyek itu sendiri.
0. Membuat proyek
Kami akan membuat proyek yang sangat sederhana di mana kami dapat mengambil entitas dari database. Saya menambahkan Lombok, Spring Cache, Spring Data JPA, dan H2 ke proyek. Meskipun, hanya Spring Cache yang bisa ditiadakan.
plugins { id 'org.springframework.boot' version '2.1.7.RELEASE' id 'io.spring.dependency-management' version '1.0.8.RELEASE' id 'java' } group = 'ru.xpendence' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' }
Kami hanya akan memiliki satu entitas, sebut saja Pengguna.
@Entity @Table(name = "users") @Data @NoArgsConstructor @ToString public class User implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "name") private String name; @Column(name = "email") private String email; public User(String name, String email) { this.name = name; this.email = email; } }
Tambahkan repositori dan layanan:
public interface UserRepository extends JpaRepository<User, Long> { } @Slf4j @Service public class UserServiceImpl implements UserService { private final UserRepository repository; public UserServiceImpl(UserRepository repository) { this.repository = repository; } @Override public User create(User user) { return repository.save(user); } @Override public User get(Long id) { log.info("getting user by id: {}", id); return repository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found by id " + id)); } }
Ketika kita memasukkan metode layanan get (), kita menulisnya di log.
Hubungkan ke proyek Spring Cache.
@SpringBootApplication @EnableCaching
Proyek sudah siap.
1. Caching hasil pengembalian
Apa yang dilakukan Spring Cache? Spring Cache hanya cache hasil kembali untuk parameter input tertentu. Mari kita periksa. Kami akan menempatkan anotasi @Cacheable atas metode layanan get () untuk menembolok data yang dikembalikan. Kami memberikan penjelasan ini dengan nama "pengguna" (kami akan menganalisis lebih lanjut mengapa hal ini dilakukan secara terpisah).
@Override @Cacheable("users") public User get(Long id) { log.info("getting user by id: {}", id); return repository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found by id " + id)); }
Untuk memeriksa cara kerjanya, kami akan menulis tes sederhana.
@RunWith(SpringRunner.class) @SpringBootTest public abstract class AbstractTest { }
@Slf4j public class UserServiceTest extends AbstractTest { @Autowired private UserService service; @Test public void get() { User user1 = service.create(new User("Vasya", "vasya@mail.ru")); User user2 = service.create(new User("Kolya", "kolya@mail.ru")); getAndPrint(user1.getId()); getAndPrint(user2.getId()); getAndPrint(user1.getId()); getAndPrint(user2.getId()); } private void getAndPrint(Long id) { log.info("user found: {}", service.get(id)); } }
Penyimpangan kecil, mengapa saya biasanya menulis AbstractTest dan mewarisi semua tes darinya.Jika kelas memiliki anotasi @SpringBootTest sendiri, konteksnya dinaikkan kembali untuk kelas tersebut setiap waktu. Karena konteksnya dapat naik selama 5 detik, atau mungkin 40 detik, hal ini sangat menghambat proses pengujian. Dalam hal ini, biasanya tidak ada perbedaan dalam konteks, dan ketika Anda menjalankan setiap kelompok tes dalam kelas yang sama, tidak perlu memulai kembali konteks. Jika kita hanya menempatkan satu anotasi, katakanlah, pada kelas abstrak, seperti dalam kasus kami, ini memungkinkan kami untuk meningkatkan konteks hanya sekali.
Oleh karena itu, saya lebih suka mengurangi jumlah konteks yang diangkat selama pengujian / perakitan, jika mungkin.
Apa yang dilakukan tes kami? Dia menciptakan dua pengguna dan kemudian menarik mereka keluar dari database 2 kali. Seperti yang kita ingat, kita meletakkan anotasi @Cacheable, yang akan menembolok nilai yang dikembalikan. Setelah menerima objek dari metode get (), kami menampilkan objek ke log. Selain itu, kami mencatat informasi tentang setiap kunjungan oleh aplikasi ke metode get ().
Jalankan tes. Inilah yang kami dapatkan di konsol.
getting user by id: 1 user found: User(id=1, name=Vasya, email=vasya@mail.ru) getting user by id: 2 user found: User(id=2, name=Kolya, email=kolya@mail.ru) user found: User(id=1, name=Vasya, email=vasya@mail.ru) user found: User(id=2, name=Kolya, email=kolya@mail.ru)
Seperti yang kita lihat, dua kali pertama kita benar-benar pergi ke metode get () dan benar-benar mendapatkan pengguna dari database. Dalam semua kasus lain, tidak ada panggilan nyata ke metode ini, aplikasi mengambil data cache dengan kunci (dalam hal ini, ini id).
2. Deklarasi kunci caching
Ada beberapa situasi ketika beberapa parameter datang ke metode cache. Dalam hal ini, mungkin perlu untuk menentukan parameter dimana caching akan terjadi. Kami menambahkan contoh ke metode yang akan menyimpan entitas yang dirakit oleh parameter ke dalam database, tetapi jika entitas dengan nama yang sama sudah ada, kami tidak akan menyimpannya. Untuk melakukan ini, kita akan mendefinisikan parameter nama sebagai kunci untuk caching. Ini akan terlihat seperti ini:
@Override @Cacheable(value = "users", key = "#name") public User create(String name, String email) { log.info("creating user with parameters: {}, {}", name, email); return repository.save(new User(name, email)); }
Mari kita tulis tes yang sesuai:
@Test public void create() { createAndPrint("Ivan", "ivan@mail.ru"); createAndPrint("Ivan", "ivan1122@mail.ru"); createAndPrint("Sergey", "ivan@mail.ru"); log.info("all entries are below:"); service.getAll().forEach(u -> log.info("{}", u.toString())); } private void createAndPrint(String name, String email) { log.info("created user: {}", service.create(name, email)); }
Kami akan mencoba membuat tiga pengguna, yang dua di antaranya namanya sama
createAndPrint("Ivan", "ivan@mail.ru"); createAndPrint("Ivan", "ivan1122@mail.ru");
dan untuk dua email yang akan cocok
createAndPrint("Ivan", "ivan@mail.ru"); createAndPrint("Sergey", "ivan@mail.ru");
Dalam metode pembuatan, kami mencatat setiap fakta bahwa metode tersebut dipanggil, dan juga, kami akan mencatat semua entitas yang dikembalikan metode ini kepada kami. Hasilnya akan seperti ini:
creating user with parameters: Ivan, ivan@mail.ru created user: User(id=1, name=Ivan, email=ivan@mail.ru) created user: User(id=1, name=Ivan, email=ivan@mail.ru) creating user with parameters: Sergey, ivan@mail.ru created user: User(id=2, name=Sergey, email=ivan@mail.ru) all entries are below: User(id=1, name=Ivan, email=ivan@mail.ru) User(id=2, name=Sergey, email=ivan@mail.ru)
Kita melihat bahwa, pada kenyataannya, aplikasi itu memanggil metode 3 kali, dan masuk ke dalamnya hanya dua kali. Setelah kunci cocok dengan metode, dan itu hanya mengembalikan nilai yang di-cache.
3. Caching paksa. @CachePut
Ada situasi ketika kita ingin menembolok nilai kembali untuk beberapa entitas, tetapi pada saat yang sama, kita perlu memperbarui cache. Untuk kebutuhan seperti itu, anotasi @CachePut ada. Itu melewati aplikasi ke dalam metode, sambil memperbarui cache untuk nilai kembali, bahkan jika itu sudah di-cache.
Tambahkan beberapa metode di mana kami akan menyelamatkan pengguna. Kami akan menandai salah satunya dengan penjelasan @Cacheable biasa, yang kedua dengan @CachePut.
@Override @Cacheable(value = "users", key = "#user.name") public User createOrReturnCached(User user) { log.info("creating user: {}", user); return repository.save(user); } @Override @CachePut(value = "users", key = "#user.name") public User createAndRefreshCache(User user) { log.info("creating user: {}", user); return repository.save(user); }
Metode pertama hanya akan mengembalikan nilai-nilai yang di-cache, yang kedua akan memaksa cache untuk diperbarui. Caching akan dilakukan menggunakan kunci # user.name. Kami akan menulis tes yang sesuai.
@Test public void createAndRefresh() { User user1 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru")); log.info("created user1: {}", user1); User user2 = service.createOrReturnCached(new User("Vasya", "misha@mail.ru")); log.info("created user2: {}", user2); User user3 = service.createAndRefreshCache(new User("Vasya", "kolya@mail.ru")); log.info("created user3: {}", user3); User user4 = service.createOrReturnCached(new User("Vasya", "petya@mail.ru")); log.info("created user4: {}", user4); }
Menurut logika yang telah dijelaskan, pertama kali pengguna dengan nama "Vasya" disimpan melalui metode createOrReturnCached (), kita akan menerima entitas yang di-cache, dan aplikasi tidak akan masuk ke metode itu sendiri. Jika kita memanggil metode createAndRefreshCache (), entitas yang di-cache untuk kunci bernama "Vasya" akan ditimpa dalam cache. Ayo jalankan tes dan lihat apa yang akan ditampilkan di konsol.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru) created user1: User(id=1, name=Vasya, email=vasya@mail.ru) created user2: User(id=1, name=Vasya, email=vasya@mail.ru) creating user: User(id=null, name=Vasya, email=kolya@mail.ru) created user3: User(id=2, name=Vasya, email=kolya@mail.ru) created user4: User(id=2, name=Vasya, email=kolya@mail.ru)
Kami melihat bahwa user1 telah berhasil menulis ke database dan cache. Ketika kami mencoba merekam pengguna dengan nama yang sama lagi, kami mendapatkan hasil cache dari panggilan pertama (user2, yang idnya sama dengan user1, yang memberi tahu kami bahwa pengguna tidak ditulis, dan ini hanya cache). Selanjutnya, kami menulis pengguna ketiga melalui metode kedua, yang, bahkan dengan hasil cache, masih memanggil metode dan menulis hasil baru ke cache. Ini adalah user3. Seperti yang bisa kita lihat, dia sudah memiliki id baru. Setelah itu, kami memanggil metode pertama, yang mengambil cache baru yang ditambahkan oleh user3.
4. Penghapusan dari cache. @CacheEvict
Terkadang menjadi sulit untuk memperbarui beberapa data dalam cache. Sebagai contoh, suatu entitas telah dihapus dari database, tetapi masih dapat diakses dari cache. Untuk menjaga konsistensi data, kita harus setidaknya tidak menyimpan data yang dihapus dalam cache.
Tambahkan beberapa metode lagi ke layanan.
@Override public void delete(Long id) { log.info("deleting user by id: {}", id); repository.deleteById(id); } @Override @CacheEvict("users") public void deleteAndEvict(Long id) { log.info("deleting user by id: {}", id); repository.deleteById(id); }
Yang pertama hanya akan menghapus pengguna, yang kedua juga akan menghapusnya, tetapi kami akan menandainya dengan anotasi @CacheEvict. Tambahkan tes yang akan membuat dua pengguna, setelah itu satu akan dihapus melalui metode sederhana, dan yang kedua melalui metode beranotasi. Setelah itu, kita akan mendapatkan pengguna ini melalui metode get ().
@Test public void delete() { User user1 = service.create(new User("Vasya", "vasya@mail.ru")); log.info("{}", service.get(user1.getId())); User user2 = service.create(new User("Vasya", "vasya@mail.ru")); log.info("{}", service.get(user2.getId())); service.delete(user1.getId()); service.deleteAndEvict(user2.getId()); log.info("{}", service.get(user1.getId())); log.info("{}", service.get(user2.getId())); }
Adalah logis bahwa karena pengguna kami sudah di-cache, penghapusan tidak akan mencegah kami dari mendapatkannya, karena itu di-cache. Mari kita lihat log.
getting user by id: 1 User(id=1, name=Vasya, email=vasya@mail.ru) getting user by id: 2 User(id=2, name=Vasya, email=vasya@mail.ru) deleting user by id: 1 deleting user by id: 2 User(id=1, name=Vasya, email=vasya@mail.ru) getting user by id: 2 javax.persistence.EntityNotFoundException: User not found by id 2
Kita melihat bahwa aplikasi dengan aman pergi kedua kali ke metode get () dan Spring cache entitas ini. Selanjutnya, kami menghapusnya melalui metode yang berbeda. Kami menghapus yang pertama dengan cara biasa, dan nilai cache tetap, jadi ketika kami mencoba untuk mendapatkan pengguna di bawah id 1, kami berhasil. Ketika kami mencoba untuk mendapatkan pengguna 2, metode ini mengembalikan EntityNotFoundException - tidak ada pengguna seperti itu di cache.
5. Pengaturan pengelompokan. @Caching
Terkadang satu metode membutuhkan beberapa pengaturan caching. Anotasi @Caching digunakan untuk tujuan ini. Mungkin terlihat seperti ini:
@Caching( cacheable = { @Cacheable("users"), @Cacheable("contacts") }, put = { @CachePut("tables"), @CachePut("chairs"), @CachePut(value = "meals", key = "#user.email") }, evict = { @CacheEvict(value = "services", key = "#user.name") } ) void cacheExample(User user) { }
Ini adalah satu-satunya cara untuk mengelompokkan anotasi. Jika Anda mencoba untuk menumpuk sesuatu seperti
@CacheEvict("users") @CacheEvict("meals") @CacheEvict("contacts") @CacheEvict("tables") void cacheExample(User user) { }
maka IDEA akan memberi tahu Anda bahwa ini bukan masalahnya.
6. Konfigurasi yang fleksibel. Cachemanager
Akhirnya, kami menemukan cache, dan berhenti menjadi sesuatu yang tidak bisa dipahami dan menakutkan bagi kami. Sekarang mari kita lihat di bawah tenda dan lihat bagaimana kita dapat mengkonfigurasi caching secara umum.
Untuk tugas seperti itu, ada CacheManager. Itu ada dimanapun Spring Cache berada. Ketika kami menambahkan anotasi @EnableCache, manajer cache seperti itu akan secara otomatis dibuat oleh Spring. Kami dapat memverifikasi ini jika kami membungkus ApplicationContext secara otomatis dan membukanya di breakpoint. Di antara tempat sampah lainnya, akan ada kacang cacheManager.

Saya menghentikan aplikasi pada tahap ketika dua pengguna telah dibuat dan dimasukkan ke dalam cache. Jika kita memanggil kacang yang kita butuhkan melalui Evaluasi Ekspresi, kita akan melihat bahwa memang ada kacang seperti itu, ia memiliki ConcurentMapCache dengan kunci "pengguna" dan nilai ConcurrentHashMap, yang sudah berisi pengguna yang di-cache.

Kami, pada gilirannya, dapat membuat cache manager kami, dengan Habr dan programmer, dan kemudian menyempurnakannya sesuai selera kami.
@Bean("habrCacheManager") public CacheManager cacheManager() { return null; }
Tetap hanya memilih cache manager mana yang akan kita gunakan, karena ada banyak dari mereka. Saya tidak akan mencantumkan semua manajer cache, itu akan cukup untuk mengetahui bahwa ada seperti:
- SimpleCacheManager adalah pengelola cache paling sederhana, nyaman untuk dipelajari dan diuji.
- ConcurrentMapCacheManager - Lazily menginisialisasi instance yang dikembalikan untuk setiap permintaan. Juga disarankan untuk menguji dan mempelajari cara bekerja dengan cache, serta untuk beberapa tindakan sederhana seperti kita. Untuk pekerjaan serius dengan cache, implementasi di bawah ini direkomendasikan.
- JCacheCacheManager , EhCacheCacheManager , CaffeineCacheManager adalah manajer cache βmitra rekananβ yang serius yang dapat dikustomisasi secara fleksibel dan melakukan tugas dari serangkaian tindakan yang sangat luas.
Sebagai bagian dari postingan sederhana saya, saya tidak akan menjelaskan manajer cache dari tiga yang terakhir. Sebagai gantinya, kami akan melihat beberapa aspek pengaturan pengelola cache menggunakan ConcurrentMapCacheManager sebagai contoh.
Jadi, mari kita buat ulang manajer cache kita.
@Bean("habrCacheManager") public CacheManager cacheManager() { return new ConcurrentMapCacheManager(); }
Manajer cache kami sudah siap.
7. Pengaturan cache. Waktu hidup, ukuran maksimum dan sebagainya.
Untuk melakukan ini, kita memerlukan perpustakaan Google Guava yang cukup populer. Saya mengambil yang terakhir.
compile group: 'com.google.guava', name: 'guava', version: '28.1-jre'
Saat membuat pengelola cache, kami mendefinisikan kembali metode createConcurrentMapCache, di mana kami akan memanggil CacheBuilder dari Guava. Dalam prosesnya, kami akan diminta untuk mengonfigurasi cache manager dengan menginisialisasi metode berikut:
- maximumSize - ukuran maksimum dari nilai-nilai yang bisa disimpan oleh cache. Menggunakan parameter ini, Anda dapat menemukan upaya untuk menemukan kompromi antara beban pada database dan JVM RAM.
- refreshAfterWrite - waktu setelah menulis nilai ke cache, setelah itu akan diperbarui secara otomatis.
- expireAfterAccess - nilai seumur hidup setelah panggilan terakhir untuk itu.
- expireAfterWrite - nilai seumur hidup setelah menulis ke cache. Ini adalah parameter yang akan kita tentukan.
dan lainnya.
Kami mendefinisikan dalam manajer masa pakai catatan. Agar tidak menunggu lama, atur 1 detik.
@Bean("habrCacheManager") public CacheManager cacheManager() { return new ConcurrentMapCacheManager() { @Override protected Cache createConcurrentMapCache(String name) { return new ConcurrentMapCache( name, CacheBuilder.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .build().asMap(), false); } }; }
Kami menulis tes yang sesuai dengan kasus ini.
@Test public void checkSettings() throws InterruptedException { User user1 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru")); log.info("{}", service.get(user1.getId())); User user2 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru")); log.info("{}", service.get(user2.getId())); Thread.sleep(1000L); User user3 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru")); log.info("{}", service.get(user3.getId())); }
Kami menyimpan beberapa nilai ke database, dan jika data di-cache, kami tidak menyimpan apa pun. Pertama, kita menyimpan dua nilai, lalu kita menunggu 1 detik sampai cache mati, setelah itu kita menyimpan nilai lain.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru) getting user by id: 1 User(id=1, name=Vasya, email=vasya@mail.ru) User(id=1, name=Vasya, email=vasya@mail.ru) creating user: User(id=null, name=Vasya, email=vasya@mail.ru) getting user by id: 2 User(id=2, name=Vasya, email=vasya@mail.ru)
Log menunjukkan bahwa pertama kami membuat pengguna, lalu kami mencoba yang lain, tetapi karena data di-cache, kami mendapatkannya dari cache (dalam kedua kasus, saat menyimpan dan ketika mendapatkan dari database). Kemudian cache menjadi buruk, karena catatan memberi tahu kami tentang penghematan aktual dan penerimaan aktual pengguna.
8. Untuk meringkas
Cepat atau lambat, pengembang dihadapkan pada kebutuhan untuk mengimplementasikan caching dalam proyek. Saya harap artikel ini membantu Anda memahami subjek dan melihat masalah caching dengan lebih berani.
Github dari proyek di sini:
https://github.com/promoscow/cache