Skip to content

Commit 90926de

Browse files
authored
Merge pull request #15 from yannbriancon/improve-detection
Improve detection
2 parents 3018ac6 + b9e9054 commit 90926de

File tree

12 files changed

+306
-110
lines changed

12 files changed

+306
-110
lines changed

src/main/java/com/yannbriancon/exception/NPlusOneQueriesException.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
package com.yannbriancon.exception;
22

3-
import org.hibernate.CallbackException;
4-
53
/**
64
* Exception triggered when detecting N+1 queries
75
*/
8-
public class NPlusOneQueriesException extends CallbackException {
9-
public NPlusOneQueriesException(String message, Exception cause) {
10-
super(message, cause);
11-
}
12-
6+
public class NPlusOneQueriesException extends RuntimeException {
137
public NPlusOneQueriesException(String message) {
148
super(message);
159
}

src/main/java/com/yannbriancon/interceptor/HibernateQueryInterceptor.java

Lines changed: 118 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import com.yannbriancon.exception.NPlusOneQueriesException;
44
import org.hibernate.EmptyInterceptor;
55
import org.hibernate.Transaction;
6-
import org.hibernate.proxy.HibernateProxy;
76
import org.slf4j.Logger;
87
import org.slf4j.LoggerFactory;
98
import org.springframework.boot.context.properties.EnableConfigurationProperties;
109
import org.springframework.stereotype.Component;
1110

11+
import javax.persistence.EntityManager;
1212
import java.io.Serializable;
1313
import java.util.HashMap;
1414
import java.util.HashSet;
@@ -24,22 +24,44 @@ public class HibernateQueryInterceptor extends EmptyInterceptor {
2424
private transient ThreadLocal<Long> threadQueryCount = new ThreadLocal<>();
2525

2626
private transient ThreadLocal<Set<String>> threadPreviouslyLoadedEntities =
27-
ThreadLocal.withInitial(new EmptySetSupplier());
27+
ThreadLocal.withInitial(new EmptySetSupplier<>());
2828

29-
private transient ThreadLocal<Map<String, String>> threadProxyMethodEntityMapping =
30-
ThreadLocal.withInitial(new EmptyMapSupplier());
29+
private transient ThreadLocal<Map<String, SelectQueriesInfo>> threadSelectQueriesInfoPerProxyMethod =
30+
ThreadLocal.withInitial(new EmptyMapSupplier<>());
3131

3232
private static final Logger LOGGER = LoggerFactory.getLogger(HibernateQueryInterceptor.class);
3333

3434
private final HibernateQueryInterceptorProperties hibernateQueryInterceptorProperties;
3535

36-
private final String HIBERNATE_PROXY_PREFIX = "org.hibernate.proxy";
37-
private final String PROXY_METHOD_PREFIX = "com.sun.proxy";
36+
private static final String HIBERNATE_PROXY_PREFIX = "org.hibernate.proxy";
37+
private static final String PROXY_METHOD_PREFIX = "com.sun.proxy";
3838

39-
public HibernateQueryInterceptor(HibernateQueryInterceptorProperties hibernateQueryInterceptorProperties) {
39+
public HibernateQueryInterceptor(
40+
HibernateQueryInterceptorProperties hibernateQueryInterceptorProperties
41+
) {
4042
this.hibernateQueryInterceptorProperties = hibernateQueryInterceptorProperties;
4143
}
4244

45+
/**
46+
* Reset the N+1 query detection state
47+
*/
48+
private void resetNPlusOneQueryDetectionState() {
49+
threadPreviouslyLoadedEntities.set(new HashSet<>());
50+
threadSelectQueriesInfoPerProxyMethod.set(new HashMap<>());
51+
}
52+
53+
/**
54+
* Clear the Hibernate Session and reset the N+1 query detection state
55+
* <p>
56+
* Clearing the Hibernate Session is necessary to detect N+1 queries in tests as they would be in production.
57+
* Otherwise, every objects created in the setup of the tests would already be loaded in the Session and would
58+
* hide potential N+1 queries.
59+
*/
60+
public void clearNPlusOneQuerySession(EntityManager entityManager) {
61+
entityManager.clear();
62+
this.resetNPlusOneQueryDetectionState();
63+
}
64+
4365
/**
4466
* Start or reset the query count to 0 for the considered thread
4567
*/
@@ -55,13 +77,17 @@ public Long getQueryCount() {
5577
}
5678

5779
/**
58-
* Increment the query count for the considered thread for each new statement if the count has been initialized
80+
* Detect the N+1 queries by keeping the history of sql queries generated per proxy method.
81+
* Increment the query count for the considered thread for each new statement if the count has been initialized.
5982
*
6083
* @param sql Query to be executed
6184
* @return Query to be executed
6285
*/
6386
@Override
6487
public String onPrepareStatement(String sql) {
88+
if (hibernateQueryInterceptorProperties.isnPlusOneDetectionEnabled()) {
89+
updateSelectQueriesInfoPerProxyMethod(sql);
90+
}
6591
Long count = threadQueryCount.get();
6692
if (count != null) {
6793
threadQueryCount.set(count + 1);
@@ -77,34 +103,20 @@ public String onPrepareStatement(String sql) {
77103
*/
78104
@Override
79105
public void afterTransactionCompletion(Transaction tx) {
80-
threadPreviouslyLoadedEntities.set(new HashSet<>());
81-
threadProxyMethodEntityMapping.set(new HashMap<>());
106+
this.resetNPlusOneQueryDetectionState();
82107
}
83108

84109
/**
85-
* Detect the N+1 queries by checking if two calls were made to getEntity for the same instance
86-
* <p>
87-
* The first call is made with the instance filled with a {@link HibernateProxy}
88-
* and the second is made after a query was executed to fetch the data in the Entity
110+
* Detect the N+1 queries by keeping the history of the entities previously gotten.
89111
*
90112
* @param entityName Name of the entity to get
91113
* @param id Id of the entity to get
92114
*/
93115
@Override
94116
public Object getEntity(String entityName, Serializable id) {
95117
if (hibernateQueryInterceptorProperties.isnPlusOneDetectionEnabled()) {
96-
detectNPlusOneQueriesOfMissingQueryEagerFetching(entityName, id);
97-
detectNPlusOneQueriesOfMissingEntityFieldLazyFetching(entityName, id);
98-
}
99-
100-
Set<String> previouslyLoadedEntities = threadPreviouslyLoadedEntities.get();
101-
102-
if (previouslyLoadedEntities.contains(entityName + id)) {
103-
previouslyLoadedEntities.remove(entityName + id);
104-
threadPreviouslyLoadedEntities.set(previouslyLoadedEntities);
105-
} else {
106-
previouslyLoadedEntities.add(entityName + id);
107-
threadPreviouslyLoadedEntities.set(previouslyLoadedEntities);
118+
detectNPlusOneQueriesFromMissingEagerFetchingOnAQuery(entityName, id);
119+
detectNPlusOneQueriesFromClassFieldEagerFetching(entityName);
108120
}
109121

110122
return null;
@@ -113,23 +125,24 @@ public Object getEntity(String entityName, Serializable id) {
113125
/**
114126
* Detect the N+1 queries caused by a missing eager fetching configuration on a query with a lazy loaded field
115127
* <p>
116-
* <p>
117128
* Detection checks:
118129
* - The getEntity was called twice for the couple (entity, id)
119-
* <p>
120130
* - There is an occurrence of hibernate proxy followed by entity class in the stackTraceElements
121131
* Avoid detecting calls to queries like findById and queries with eager fetching on some entity fields
122132
*
123133
* @param entityName Name of the entity
124134
* @param id Id of the entity objecy
125-
* @return Boolean telling whether N+1 queries were detected or not
126135
*/
127-
private boolean detectNPlusOneQueriesOfMissingQueryEagerFetching(String entityName, Serializable id) {
136+
private void detectNPlusOneQueriesFromMissingEagerFetchingOnAQuery(String entityName, Serializable id) {
128137
Set<String> previouslyLoadedEntities = threadPreviouslyLoadedEntities.get();
129138

130139
if (!previouslyLoadedEntities.contains(entityName + id)) {
131-
return false;
140+
previouslyLoadedEntities.add(entityName + id);
141+
threadPreviouslyLoadedEntities.set(previouslyLoadedEntities);
142+
return;
132143
}
144+
previouslyLoadedEntities.remove(entityName + id);
145+
threadPreviouslyLoadedEntities.set(previouslyLoadedEntities);
133146

134147
// Detect N+1 queries by searching for newest occurrence of Hibernate proxy followed by entity class in stack
135148
// elements
@@ -147,70 +160,97 @@ private boolean detectNPlusOneQueriesOfMissingQueryEagerFetching(String entityNa
147160
}
148161

149162
if (originStackTraceElement == null) {
150-
return false;
163+
return;
151164
}
152165

153166
String errorMessage = "N+1 queries detected on a getter of the entity " + entityName +
154167
"\n at " + originStackTraceElement.toString() +
155168
"\n Hint: Missing Eager fetching configuration on the query that fetched the object of " +
156169
"type " + entityName + "\n";
157170
logDetectedNPlusOneQueries(errorMessage);
171+
}
158172

159-
return true;
173+
/**
174+
* Update the select queries info per proxy method to be able to detect potential N+1 queries
175+
* due to Eager Fetching on a field of a class
176+
* <p>
177+
* Checks:
178+
* - Detect queries that would not fit the N+1 queries problem, non select queries, and remove the entry
179+
* - Detect multiple calls to same proxy method and reset the entry to avoid false positive
180+
* - Detect select queries that could be potential N+1 queries and increment the count
181+
*/
182+
private void updateSelectQueriesInfoPerProxyMethod(String sql) {
183+
Optional<String> optionalProxyMethodName = getProxyMethodName();
184+
if (!optionalProxyMethodName.isPresent()) {
185+
return;
186+
}
187+
String proxyMethodName = optionalProxyMethodName.get();
188+
189+
boolean isSelectQuery = sql.toLowerCase().startsWith("select");
190+
191+
Map<String, SelectQueriesInfo> selectQueriesInfoPerProxyMethod = threadSelectQueriesInfoPerProxyMethod.get();
192+
193+
// The N+1 queries problem is only related to select queries
194+
// So we remove the entry when detecting non select query for the proxy method
195+
if (!isSelectQuery) {
196+
selectQueriesInfoPerProxyMethod.remove(proxyMethodName);
197+
threadSelectQueriesInfoPerProxyMethod.set(selectQueriesInfoPerProxyMethod);
198+
return;
199+
}
200+
201+
SelectQueriesInfo selectQueriesInfo = selectQueriesInfoPerProxyMethod.get(proxyMethodName);
202+
203+
// Handle several calls to the same proxy method by resetting the SelectQueriesInfo
204+
// when the initial select query is detected
205+
if (selectQueriesInfo == null || selectQueriesInfo.getInitialSelectQuery().equals(sql)) {
206+
selectQueriesInfoPerProxyMethod.put(proxyMethodName, new SelectQueriesInfo(sql));
207+
threadSelectQueriesInfoPerProxyMethod.set(selectQueriesInfoPerProxyMethod);
208+
return;
209+
}
210+
211+
selectQueriesInfoPerProxyMethod.put(proxyMethodName, selectQueriesInfo.incrementSelectQueriesCount());
212+
threadSelectQueriesInfoPerProxyMethod.set(selectQueriesInfoPerProxyMethod);
160213
}
161214

162215
/**
163216
* Detect the N+1 queries caused by a missing lazy fetching configuration on an entity field
164217
* <p>
165-
* Detection checks:
166-
* - The getEntity was called twice for the couple (entity, id)
167-
* <p>
168-
* - The query that triggered the fetching of the entity object was first called for a different entity
169-
* Avoid detecting calls to queries like findById
170-
*
171-
* @param entityName Name of the entity
172-
* @param id Id of the entity objecy
173-
* @return Boolean telling whether N+1 queries were detected or not
218+
* Detection checks that several select queries were generated from the same proxy method
174219
*/
175-
private boolean detectNPlusOneQueriesOfMissingEntityFieldLazyFetching(String entityName, Serializable id) {
220+
private void detectNPlusOneQueriesFromClassFieldEagerFetching(String entityName) {
176221
Optional<String> optionalProxyMethodName = getProxyMethodName();
177222
if (!optionalProxyMethodName.isPresent()) {
178-
return false;
223+
return;
179224
}
180225
String proxyMethodName = optionalProxyMethodName.get();
181226

182-
Set<String> previouslyLoadedEntities = threadPreviouslyLoadedEntities.get();
183-
Map<String, String> proxyMethodEntityMapping = threadProxyMethodEntityMapping.get();
184-
185-
boolean nPlusOneQueriesDetected = false;
186-
if (
187-
previouslyLoadedEntities.contains(entityName + id)
188-
&& proxyMethodEntityMapping.containsKey(proxyMethodName)
189-
&& !proxyMethodEntityMapping.get(proxyMethodName).equals(entityName)
190-
) {
191-
nPlusOneQueriesDetected = true;
192-
193-
String errorMessage = "N+1 queries detected on a query for the entity " + entityName;
194-
195-
// Find origin of the N+1 queries in client package
196-
// by getting oldest occurrence of proxy method in stack elements
197-
StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
198-
199-
for (int i = stackTraceElements.length - 1; i >= 1; i--) {
200-
if (stackTraceElements[i - 1].getClassName().indexOf(PROXY_METHOD_PREFIX) == 0) {
201-
errorMessage += "\n at " + stackTraceElements[i].toString();
202-
break;
203-
}
204-
}
227+
Map<String, SelectQueriesInfo> selectQueriesInfoPerProxyMethod = threadSelectQueriesInfoPerProxyMethod.get();
228+
SelectQueriesInfo selectQueriesInfo = selectQueriesInfoPerProxyMethod.get(proxyMethodName);
229+
if (selectQueriesInfo == null || selectQueriesInfo.getSelectQueriesCount() < 2) {
230+
return;
231+
}
205232

206-
errorMessage += "\n Hint: Missing Lazy fetching configuration on a field of one of the entities " +
207-
"fetched in the query\n";
233+
// Reset the count to 1 to log a message once per additional query
234+
selectQueriesInfoPerProxyMethod.put(proxyMethodName, selectQueriesInfo.resetSelectQueriesCount());
235+
threadSelectQueriesInfoPerProxyMethod.set(selectQueriesInfoPerProxyMethod);
208236

209-
logDetectedNPlusOneQueries(errorMessage);
237+
String errorMessage = "N+1 queries detected with eager fetching on the entity " + entityName;
238+
239+
// Find origin of the N+1 queries in client package
240+
// by getting oldest occurrence of proxy method in stack elements
241+
StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
242+
243+
for (int i = stackTraceElements.length - 1; i >= 1; i--) {
244+
if (stackTraceElements[i - 1].getClassName().indexOf(PROXY_METHOD_PREFIX) == 0) {
245+
errorMessage += "\n at " + stackTraceElements[i].toString();
246+
break;
247+
}
210248
}
211249

212-
proxyMethodEntityMapping.putIfAbsent(proxyMethodName, entityName);
213-
return nPlusOneQueriesDetected;
250+
errorMessage += "\n Hint: Missing Lazy fetching configuration on a field of type " + entityName + " of " +
251+
"one of the entities fetched in the query\n";
252+
253+
logDetectedNPlusOneQueries(errorMessage);
214254
}
215255

216256
/**
@@ -224,7 +264,7 @@ private Optional<String> getProxyMethodName() {
224264
for (int i = stackTraceElements.length - 1; i >= 0; i--) {
225265
StackTraceElement stackTraceElement = stackTraceElements[i];
226266

227-
if (stackTraceElement.getClassName().indexOf("com.sun.proxy") == 0) {
267+
if (stackTraceElement.getClassName().indexOf(PROXY_METHOD_PREFIX) == 0) {
228268
return Optional.of(stackTraceElement.getClassName() + stackTraceElement.getMethodName());
229269
}
230270
}
@@ -249,19 +289,19 @@ private void logDetectedNPlusOneQueries(String errorMessage) {
249289
LOGGER.error(errorMessage);
250290
break;
251291
default:
252-
throw new NPlusOneQueriesException(errorMessage, new Exception(new Throwable()));
292+
throw new NPlusOneQueriesException(errorMessage);
253293
}
254294
}
255295
}
256296

257-
class EmptySetSupplier implements Supplier<Set<String>> {
258-
public Set<String> get() {
297+
class EmptySetSupplier<T> implements Supplier<Set<T>> {
298+
public Set<T> get() {
259299
return new HashSet<>();
260300
}
261301
}
262302

263-
class EmptyMapSupplier implements Supplier<Map<String, String>> {
264-
public Map<String, String> get() {
303+
class EmptyMapSupplier<T> implements Supplier<Map<String, T>> {
304+
public Map<String, T> get() {
265305
return new HashMap<>();
266306
}
267307
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.yannbriancon.interceptor;
2+
3+
class SelectQueriesInfo {
4+
private final String initialSelectQuery;
5+
6+
private Integer selectQueriesCount = 1;
7+
8+
public SelectQueriesInfo(String initialSelectQuery) {
9+
this.initialSelectQuery = initialSelectQuery;
10+
}
11+
12+
public String getInitialSelectQuery() {
13+
return initialSelectQuery;
14+
}
15+
16+
public Integer getSelectQueriesCount() {
17+
return selectQueriesCount;
18+
}
19+
20+
public void setSelectQueriesCount(Integer selectQueriesCount) {
21+
this.selectQueriesCount = selectQueriesCount;
22+
}
23+
24+
SelectQueriesInfo incrementSelectQueriesCount() {
25+
selectQueriesCount = selectQueriesCount + 1;
26+
return this;
27+
}
28+
29+
SelectQueriesInfo resetSelectQueriesCount() {
30+
selectQueriesCount = 1;
31+
return this;
32+
}
33+
}

0 commit comments

Comments
 (0)