Problem wydajnościowy z wrapowaniem listy encji
Niedawno opublikowałem taką oto publikację na LinkedIn. Dotyczy ona sposobu wrapowania listy encji pobieranej z repozytorium Spring Data. Kod wygląda następująco.
interface VehicleRepository extends CrudRepository<Vehicle, Long> {
Set<Vehicle> findAllByPolicyId(Long policyId);
default Vehicles findVehiclesBy(Long policyId) {
return new Vehicles(findAllByPolicyId(policyId));
}
}
Ten pomysł zrodził mi się w głowie po zapoznaniu się z zasadami zawartymi w Object Calisthenics, a dokładniej jedną z nich - First Class Collections. Korzysta się z tego naprawdę przyjemnie, jednak ma to pewną wadę, o której się niedawno dowiedziałem (pozdrowienia dla Kamila 👋).
Problem wydajnościowy
Załóżmy taki scenariusz - z powodów biznesowych musimy w dwóch miejscach pobrać kolekcję encji używając tego samego id agregującego. Oczywiście wszystko dzieje się w ramach jednego przypadku biznesowego. Dodatkowo nie chcemy, aby ta lista była przekazywana przez wiele warstw jako parametr metod.
Spójrzmy jak to może wyglądać w kodzie. Wyjątkowo będzie on dzisiaj napisany w Kotlinie z racji tego, że chciałbym z nim poobcować. 😜
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Inmate {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
var id: Long? = null
var houseId: Long? = null
var name: String? = null
var role: String? = null
constructor(name: String?, houseId: Long?, role: String?) {
this.name = name
this.houseId = houseId
this.role = role
}
}
interface InmateRepository : CrudRepository<Inmate, Long> {
fun findAllByHouseId(houseId: Long): Set<Inmate>
}
@Entity
@Table(name = "house")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class House {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
var id: Long? = null
var name: String? = null
constructor(name: String?) {
this.name = name
}
}
interface HouseRepository : CrudRepository<House, Long> {
}
Tak prezentuje się ten trywialny kod. Oczywiście już nie opakowywałem zbioru Inmate w klasę, bo to nie ma tutaj znaczenia (nawet nie wiem czy można korzystać ze słowa kluczowego default w interfejsach w Kotlinie). Napiszmy teraz kod, który będzie nam potrzebny, aby sprawdzić wcześniej opisaną sytuację.
@Component
class HouseService(
val houseRepository: HouseRepository,
val inmateRepository: InmateRepository,
val transactionTemplate: TransactionTemplate
) {
val log = LoggerFactory.getLogger(HouseService::class.java)
fun call() {
val houseId = transactionTemplate.execute {
log.debug("saving house")
val house = houseRepository.save(House("Pogodny domek"))
log.debug("saving inmates")
inmateRepository.save(Inmate("Tadzik", house.id, "Tata"))
inmateRepository.save(Inmate("Grażyna", house.id, "Mama"))
inmateRepository.save(Inmate("Albert", house.id, "Syn"))
inmateRepository.save(Inmate("Roksana", house.id, "Córka"))
return@execute house.id
}
transactionTemplate.execute {
log.debug("fetching inmates by house id")
val first = houseId?.let { inmateRepository.findAllByHouseId(it) }.orEmpty()
log.debug("first size {}", first.size)
val second = houseId?.let { inmateRepository.findAllByHouseId(it) }.orEmpty()
log.debug("second size {}", second.size)
}
}
}
Jeśli umożliwimy pokazywanie wygenerowanych SQLek w logach to naszym oczom ukaże się coś takiego.
Hibernate:
/\* <criteria> \*/ select
i1_0.id,
i1_0.house_id,
i1_0.name,
i1_0.role
from
inmate i1_0
where
i1_0.house_id=?
2024-05-17T12:21:08.572+02:00 DEBUG 39328 --- [jpa-list-optimization] [ main] p.c.jpalistoptimization.HouseService : first size 4
Hibernate:
/\* <criteria> \*/ select
i1_0.id,
i1_0.house_id,
i1_0.name,
i1_0.role
from
inmate i1_0
where
i1_0.house_id=?
2024-05-17T12:21:08.573+02:00 DEBUG 39328 --- [jpa-list-optimization] [ main] p.c.jpalistoptimization.HouseService : second size 4
Robimy dwa strzały do bazy danych, aby pobrać listę mieszkańców. Cache Hibernate nie zadziałał. Jak możemy sobie z tym poradzić? Oto jedno z możliwych rozwiązań.
Rozwiązanie problemu
@Table(name = "house")
@Entity(name = "house_inmates")
@NoArgsConstructor(access = AccessLevel.PACKAGE)
class HouseInmates {
@Id
var id: Long? = null
@OneToMany(mappedBy = "houseId", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
var inmates: Set<Inmate> = HashSet()
constructor(id: Long?, inmates: Set<Inmate>) {
this.id = id
this.inmates = inmates
}
fun size(): Int {
return inmates.size
}
}
interface HouseInmatesRepository : CrudRepository<HouseInmates, Long> {
}
Możemy stworzyć encję, która odnosi się do tej samej tabeli co pierwotna encja. W jej ciele umieszczamy tylko zbiór encji, które chcemy wyciągać razem.
@Component
class HouseService(
val houseRepository: HouseRepository,
val inmateRepository: InmateRepository,
val houseInmatesRepository: HouseInmatesRepository,
val transactionTemplate: TransactionTemplate
) {
val log = LoggerFactory.getLogger(HouseService::class.java)
fun call() {
val houseId = transactionTemplate.execute {
log.debug("saving house")
val house = houseRepository.save(House("Pogodny domek"))
log.debug("saving inmates")
inmateRepository.save(Inmate("Tadzik", house.id, "Tata"))
inmateRepository.save(Inmate("Grażyna", house.id, "Mama"))
inmateRepository.save(Inmate("Albert", house.id, "Syn"))
inmateRepository.save(Inmate("Roksana", house.id, "Córka"))
return@execute house.id
}
transactionTemplate.execute {
log.debug("fetching inmates by house id with aggregation")
val third = houseId?.let { houseInmatesRepository.findById(it) } ?: Optional.empty()
third.ifPresentOrElse(
{ log.debug("third size {}", it.size()) },
{ log.debug("third size is 0") }
)
val fourth = houseId?.let { houseInmatesRepository.findById(it) } ?: Optional.empty()
fourth.ifPresentOrElse(
{ log.debug("fourth size {}", it.size()) },
{ log.debug("fourth size is 0") }
)
}
}
}
Hibernate:
select
hi1_0.id,
i1_0.house_id,
i1_0.id,
i1_0.name,
i1_0.role
from
house hi1_0
left join
inmate i1_0
on hi1_0.id=i1_0.house_id
where
hi1_0.id=?
2024-05-17T12:21:08.592+02:00 DEBUG 39328 --- [jpa-list-optimization] [ main] p.c.jpalistoptimization.HouseService : third size 4
2024-05-17T12:21:08.593+02:00 DEBUG 39328 --- [jpa-list-optimization] [ main] p.c.jpalistoptimization.HouseService : fourth size 4
Mamy już tylko jeden strzał. Spory uzysk jak na tak małą ilość pracy.
Podsumowanie
Pewnie takie podejście nie każdemu się spodoba. To prawda, nie jest eleganckie, bo mamy dwie encje działające na jeden tabelce. Ale działa i poprawia driver architektoniczny jakim jest wydajność. Jeśli faktycznie zaczniemy cierpieć z powodu zbyt wielu uderzeń do bazy danych to może warto rozważyć takie rozwiązanie. Oczywiście zdaje sobie sprawę, że to nie jest jedyny sposób poprawy wydajności. Jeśli masz jakiś inny pomysł to podziel się nim w komentarzu.