I have been prototyping my new application in Spring Data REST backed by Spring Data JPA & Hibernate, which has been a fantastic productivity booster for my team, but as the data model becomes more complex the performance is going down the tubes. Looking at the executed SQL, I see two separate but related problems:
When using a Projection
with only a few properties to reduce the size of my payload, SDR is still loading the entire entity graph, with all the overhead that incurs. EDIT: filed DATAREST-1089
There seems to be no way to specify eager loading using JPA, as SDR auto-generates the repository methods so I can't add @EntityGraph
to them. (and per DATAREST-905 below, even that doesn't work) EDIT: addressed in Cepr0's answer below, though this can only be applied to each finder method once. See DATAJPA-749
I have one key model that I use several different projections of depending on the context (list page, view page, autocomplete, related item page, etc), so implementing one custom ResourceProcessor
doesn't seem like a solution.)
Has anyone found a way around these problems? Otherwise anyone with a non-trivial object graph will see performance deteriorate drastically as their model grows.
My research:
Spring data REST Projection supports projecting only a selected fields from an entity representation. To do that, we can define those specific fields into our @Projection interface. Let's create a custom view of our Student entity with first name and last name fields.
Spring Data REST can be used to expose HATEOAS RESTful resources around Spring Data repositories. Without writing a lot of code, we can expose RESTful API around Spring Data Repositories.
Fetching a one-to-many DTO projection with JPA and Hibernate. The postDTOMap is where we are going to store all PostDTO entities that, in the end, will be returned by the query execution. The reason we are using the postDTOMap is that the parent rows are duplicated in the SQL query result set for each child record.
Spring Data JPA @Query The @Query annotation declares finder queries directly on repository methods. While similar @NamedQuery is used on domain classes, Spring Data JPA @Query annotation is used on Repository interface. This frees the domain classes from persistence specific information, which is a good thing.
To fight with 1+N issue I use the following two approaches:
@EntityGraph
I use '@EntityGraph' annotation in Repository for findAll
method. Just override it:
@Override
@EntityGraph(attributePaths = {"author", "publisher"})
Page<Book> findAll(Pageable pageable);
This approach is suitable for all "reading" methods of Repository.
Cache
I use cache to reduce the impact of 1+N issue for complex Projections.
Suppose we have Book entity to store the book data and Reading entity to store the information about the number of readings of a specific Book and its reader rating. To get this data we can make a Projection like this:
@Projection(name = "bookRating", types = Book.class)
public interface WithRatings {
String getTitle();
String getIsbn();
@Value("#{@readingRepo.getBookRatings(target)}")
Ratings getRatings();
}
Where readingRepo.getBookRatings
is the method of ReadingRepository:
@RestResource(exported = false)
@Query("select avg(r.rating) as rating, count(r) as readings from Reading r where r.book = ?1")
Ratings getBookRatings(Book book);
It also return a projection that store "rating" info:
@JsonSerialize(as = Ratings.class)
public interface Ratings {
@JsonProperty("rating")
Float getRating();
@JsonProperty("readings")
Integer getReadings();
}
The request of /books?projection=bookRating
will cause the invocation of readingRepo.getBookRatings
for every Book which will lead to redundant N queries.
To reduce the impact of this we can use the cache:
Preparing the cache in the SpringBootApplication class:
@SpringBootApplication
@EnableCaching
public class Application {
//...
@Bean
public CacheManager cacheManager() {
Cache bookRatings = new ConcurrentMapCache("bookRatings");
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(Collections.singletonList(bookRatings));
return manager;
}
}
Then adding a corresponding annotation to readingRepo.getBookRatings
method:
@Cacheable(value = "bookRatings", key = "#a0.id")
@RestResource(exported = false)
@Query("select avg(r.rating) as rating, count(r) as readings from Reading r where r.book = ?1")
Ratings getBookRatings(Book book);
And implementing the cache eviction when Book data is updated:
@RepositoryEventHandler(Reading.class)
public class ReadingEventHandler {
private final @NonNull CacheManager cacheManager;
@HandleAfterCreate
@HandleAfterSave
@HandleAfterDelete
public void evictCaches(Reading reading) {
Book book = reading.getBook();
cacheManager.getCache("bookRatings").evict(book.getId());
}
}
Now all subsequent requests of /books?projection=bookRating
will get rating data from our cache and will not cause redundant requests to the database.
More info and working example is here.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With