Sinkronisasi Permintaan Klien di Musim Semi

Hari ini saya menyarankan Anda untuk menganalisis satu tugas praktis tentang ras permintaan klien yang saya temui di MaximTelecom ketika mengembangkan back-end untuk aplikasi seluler kami MT_FREE.

Saat startup, aplikasi klien secara tidak sinkron mengirim "paket" permintaan ke API. Aplikasi memiliki pengidentifikasi clientId, yang memungkinkan untuk membedakan permintaan dari satu klien dengan yang lain. Untuk setiap permintaan di server, kode formulir dijalankan:

//      Client client = clientRepository.findByClientId(clientId); //      if(client == null){ client = clientRepository.save(new Client(clientId)); } //    

di mana entitas Klien memiliki bidang clientId, yang harus unik dan memiliki batasan unik dalam database untuk ini. Karena di Musim Semi setiap permintaan akan mengeksekusi kode ini di utas terpisah, bahkan jika ini adalah permintaan dari aplikasi klien yang sama, kesalahan formulir akan muncul:
pelanggaran integritas integritas: kendala unik atau pelanggaran indeks; Tabel UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

Kesalahan terjadi karena alasan yang jelas: 2 atau lebih utas dengan clientId yang sama menerima klien == entitas nol dan mulai membuatnya, setelah itu mereka mendapatkan kesalahan saat melakukan.

Tantangan:


Adalah perlu untuk menyinkronkan permintaan dari satu clientId sehingga hanya permintaan pertama yang menyelesaikan pembuatan entitas Klien, dan sisanya akan diblokir pada saat pembuatan dan menerima objek yang sudah dibuat.

Solusi 1


  //      if(client == null){ //   synchronized (this){ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } } 

Solusi ini berfungsi, tetapi sangat mahal, karena semua permintaan (utas) yang perlu dibuat diblokir, bahkan jika mereka menciptakan Klien dengan clientId yang berbeda dan tidak saling bersaing.

Harap perhatikan bahwa kombinasi sinkronisasi dengan anotasi @Transaksional

 @Transactional public synchronized Client getOrCreateUser(String clientId){ //      Client client = clientRepository.findByClientId(clientId); //      if(client == null){ client = clientRepository.save(new Client(clientId)); } return client; } 

kesalahan yang sama akan terjadi lagi. Alasannya adalah monitor (yang disinkronkan) dibebaskan terlebih dahulu dan utas berikutnya memasuki area yang disinkronkan, dan hanya setelah itu transaksi dilakukan oleh utas pertama pada objek proxy. Untuk mengatasi masalah ini sederhana - Anda perlu monitor dilepaskan setelah komit, oleh karena itu, disinkronkan harus disebut di atas:

  synchronized (this){ client = clientService.getOrCreateUser(clientId); } 

Keputusan 2


Saya benar-benar ingin menggunakan desain formulir:

 synchronized (clientId) 

tetapi masalahnya adalah objek clientId baru akan dibuat untuk setiap permintaan, bahkan jika nilainya setara, oleh karena itu, sinkronisasi tidak dapat dilakukan dengan cara ini. Untuk menyelesaikan masalah dengan objek clientId yang berbeda, Anda perlu menggunakan kumpulan:

 Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   synchronized (clientId.intern()){ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } } 

Solusi ini menggunakan kumpulan string java, masing-masing, permintaan dengan clientId yang setara, dengan memanggil clientId.intern (), akan menerima objek yang sama. Sayangnya, dalam praktiknya, solusi ini tidak berlaku, karena tidak mungkin untuk mengelola "membusuk" clientId, yang cepat atau lambat akan mengarah ke OutOfMemory.

Keputusan 3


Untuk menggunakan ReentrantLock, Anda memerlukan kumpulan formulir:

 private final ConcurrentMap<String, ReentrantLock> locks; 

dan kemudian:

 Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   ReentrantLock lock = locks.computeIfAbsent(clientId, (k) -> new ReentrantLock()); lock.lock(); try{ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } finally { //   lock.unlock(); } } 

Satu-satunya masalah adalah manajemen "basi" clientId, dapat diselesaikan dengan menggunakan implementasi ConcurrentMap yang tidak standar, yang sudah mendukung kedaluwarsa, misalnya, ambil jambu Cache:

 locks = CacheBuilder.newBuilder() .concurrencyLevel(4) .expireAfterWrite(Duration.ofMinutes(1)) .<String, ReentrantLock>build().asMap(); 

Keputusan 4


Solusi di atas menyinkronkan permintaan dalam satu instance. Apa yang harus dilakukan jika layanan Anda berputar pada N node dan permintaan dapat langsung berbeda? Untuk situasi ini, menggunakan perpustakaan Redisson sempurna sebagai solusi:

  Client client = clientRepository.findByClientId(clientId); //      if(client == null){ //   RLock lock = redissonClient.getFairLock(clientId); lock.lock(); try{ //    client = clientRepository.findByClientId(clientId); if(client == null){ client = clientRepository.save(new Client(clientId)); } } finally { //   lock.unlock(); } } 

Perpustakaan memecahkan masalah kunci terdistribusi menggunakan redis sebagai repositori.

Kesimpulan


Keputusan mana untuk menerapkan tentu tergantung pada skala masalah: solusi 1-3 cukup cocok untuk layanan kecil satu contoh, solusi 4 ditujukan untuk layanan terdistribusi. Perlu juga dicatat secara terpisah bahwa penyelesaian masalah ini menggunakan Redisson atau analog (misalnya, Zookeeper klasik), tentu saja, merupakan kasus khusus, karena mereka dirancang untuk berbagai tugas yang jauh lebih besar untuk sistem terdistribusi.

Dalam kasus kami, kami memilih solusi 4, karena layanan kami didistribusikan dan integrasi Redisson adalah yang paling mudah dibandingkan dengan analog.

Teman, sarankan di komentar opsi Anda untuk memecahkan masalah ini, saya akan sangat senang!
Kode sumber untuk contoh tersedia di GitHub .

Omong-omong, kami terus memperluas staf pengembangan, lowongan yang relevan dapat ditemukan di halaman karir kami.

UPD 1. Solusi dari pembaca 1


Solusi ini mengusulkan untuk tidak menyinkronkan permintaan, tetapi jika terjadi kesalahan pada formulir:
pelanggaran integritas integritas: kendala unik atau pelanggaran indeks; Tabel UK_BFJDOY2DPUSSYLQ7G1S3S1TN8: CLIENT

harus diproses dan dipanggil kembali
 client = clientRepository.findByClientId(clientId); 

atau melakukannya melalui pegas coba:
 @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000)) @Transactional public Client getOrCreateUser(String clientId) 

(terima kasih kepada Throwable untuk contoh )
Dalam hal ini, akan ada pertanyaan "ekstra" ke basis data, tetapi dalam praktiknya penciptaan entitas Klien tidak akan sering terjadi, dan jika sinkronisasi hanya diperlukan untuk menyelesaikan masalah memasukkan ke dalam basis data, maka solusi ini dapat ditiadakan.

UPD 2. Solusi dari pembaca 2


Solusi ini mengusulkan untuk melakukan sinkronisasi melalui sesi:
 HttpSession session = request.getSession(false); if (session != null) { Object mutex = WebUtils.getSessionMutex(session); synchronized (mutex) { ... } } 

Solusi ini akan berfungsi untuk layanan satu contoh, tetapi akan perlu untuk menyelesaikan masalah sehingga semua permintaan dari satu klien ke API dilakukan dalam sesi yang sama.

Source: https://habr.com/ru/post/id434714/


All Articles