1. 概述
會話與請求生命週期綁定是一種事務性模式,將持久化會話和請求週期聯繫起來。 unsurprisingly,Spring 提供了自己的實現,名為 OpenSessionInViewInterceptor,以簡化與延遲關聯的工作,從而提高開發人員的生產力。
在本教程中,首先我們將學習攔截器的工作原理,然後我們將看到這種 備受爭議 的模式如何對我們的應用程序來説是雙刃劍!
2. 介紹視圖中的 Open Session in View (OSIV)
為了更好地理解 Open Session in View (OSIV) 在視圖中的作用,假設我們有一個傳入的請求:
- Spring 在請求的開始時會創建一個新的 Hibernate Session。這些 Session 與數據庫並不一定連接。
- 當應用程序需要 Session 時,它會重用已經存在的那個。
- 在請求結束時,相同的攔截器會關閉那個 Session。
乍一看,啓用此功能似乎很有道理。畢竟,框架處理了會話的創建和終止,因此開發者不必關心這些看似低級別的細節。這反過來又提高了開發人員的生產力。
然而,有時,OSIV 可能會在生產環境中導致微妙的性能問題。通常,這類問題很難診斷。
2.1. Spring Boot
默認情況下,Spring Boot 應用中 OSIV 已啓用。儘管如此,自 Spring Boot 2.0 版本起,它會在我們未明確配置的情況下,在應用程序啓動時提醒我們已啓用。
spring.jpa.open-in-view is enabled by default. Therefore, database
queries may be performed during view rendering.Explicitly configure
spring.jpa.open-in-view to disable this warning無論如何,我們可以通過使用 spring.jpa.open-in-view 配置屬性來禁用 OSIV:
spring.jpa.open-in-view=false2.2. 模式或反模式?
一直以來,對OSIV存在着不同的反應。支持OSIV陣營的主要論點是開發人員的生產力,尤其是在處理懶散關聯時。
另一方面,數據庫性能問題是反對OSIV運動的主要論點。稍後,我們將對這兩個論點進行詳細評估。
3. 懶加載英雄
由於 OSIV 將 會話 生命週期綁定到每個請求上,Hibernate 可以在從顯式 @Transactional 服務返回後,仍然能夠解析懶加載關聯。
為了更好地理解這一點,我們假設我們正在建模用户及其安全權限:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue
private Long id;
private String username;
@ElementCollection
private Set<String> permissions;
// getters and setters
}類似於其他一對多和多對多的關係,權限屬性是一個延遲加載的集合。
然後,在我們的服務層實現中,我們使用 @Transactional 顯式地定義事務邊界:
@Service
public class SimpleUserService implements UserService {
private final UserRepository userRepository;
public SimpleUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
return userRepository.findByUsername(username);
}
}3.1. 預期行為
以下是當我們代碼調用 findOne 方法時,預期會發生的情況:
- 首先,Spring 代理攔截該調用,並獲取當前事務或如果不存在則創建事務。
- 然後,它將方法調用委託給我們的實現。
- 最後,代理提交事務,並相應地關閉底層的
<em Session</em>>。畢竟,我們只需要在服務層中使用該<em Session</em>>。
在 <em findOne</em>> 方法的實現中,我們沒有初始化 <em permissions</em> 集合。因此,我們不應該在方法返回後使用 <em permissions</em>>。如果我們在該屬性上進行迭代,我們應該收到 <em LazyInitializationException</em>>。
3.2. 歡迎進入真實世界
讓我們編寫一個簡單的 REST 控制器,以查看是否可以使用 權限 屬性:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{username}")
public ResponseEntity<?> findOne(@PathVariable String username) {
return userService
.findOne(username)
.map(DetailedUserDto::fromEntity)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}在這裏,我們遍歷在實體到DTO轉換過程中出現的權限。由於我們預計該轉換可能會失敗並拋出懶加載異常,因此以下測試不應該通過:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {
@Autowired
private UserRepository userRepository;
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setUp() {
User user = new User();
user.setUsername("root");
user.setPermissions(new HashSet<>(Arrays.asList("PERM_READ", "PERM_WRITE")));
userRepository.save(user);
}
@Test
void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception {
mockMvc.perform(get("/users/root"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("root"))
.andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE")));
}
}然而,這個測試沒有拋出任何異常,並且通過了測試。
因為OSIV在請求的開始創建了一個Session,事務代理使用當前可用的Session,而不是創建全新的Session。
因此,儘管我們可能會預期,但實際上我們可以在顯式@Transactional之外使用permissions屬性。 此外,這些惰性關聯可以在當前請求的作用域中任何地方獲取。
3.3. 關於開發者生產力
如果未啓用 OSIV,則必須手動在事務上下文中初始化所有必要的惰性關聯。 最基本的(通常也是錯誤的)方法是使用 Hibernate.initialize() 方法:
@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));
return user;
}目前,OSIV 對開發人員生產力的影響已經顯而易見。然而,這並不總是關於開發人員生產力的。
4. 性能惡魔
假設我們需要擴展我們簡單的用户服務,使其在從數據庫中檢索用户後,調用另一個遠程服務:
@Override
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
if (user.isPresent()) {
// remote call
}
return user;
}在這裏,我們已移除 @Transactional 註解,因為我們顯然不希望在等待遠程服務時保留連接的 Session。
4.1. 避免混合IO
讓我們澄清如果不移除 @Transactional 註解時會發生什麼。 假設新的遠程服務響應速度比平時稍慢:
- 首先,Spring代理獲取當前的 `Session` 或創建一個新的 `Session`。 無論哪種方式,這個 `Session` 都尚未連接。 也就是説,它沒有使用連接池中的任何連接。
- 當我們執行查詢以查找用户時,`Session` 變為連接狀態,並從連接池中借用一個 `Connection`。
- 如果整個方法是事務性的,則該方法在保持借用的 `Connection` 的狀態下繼續調用慢速遠程服務。
想象一下,在此期間,我們收到大量對 findOne 方法的調用。 然後,過一段時間,所有 Connection 可能會等待來自該 API 調用的響應。 因此,我們很快可能會耗盡數據庫連接。
在事務上下文中將數據庫 IO 與其他類型的 IO 混合使用是一種不良現象,我們應該避免這種情況。
總之,由於我們從我們的服務中移除了 @Transactional 註解,我們預計會安全。
4.2. 耗盡連接池
當 OSIV 處於活動狀態時, 當前請求作用域中始終存在一個會話 ,即使我們移除 @Transactional 。 儘管這個會話 最初未連接,但在我們第一次數據庫 IO 之後,它會連接並保持連接直到請求結束。
因此,這個看似無辜且最近被優化的服務實現在存在 OSIV 的情況下,註定會釀成災禍。
@Override
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
if (user.isPresent()) {
// remote call
}
return user;
}啓用 OSIV 時會發生以下情況:
- 在請求的開始時,相應的過濾器會創建一個新的<em>會話</em>。
- 當我們調用<em>findByUsername</em>方法時,該<em>會話</em>會從池中借用一個<em>連接</em>。
- 該<em>會話</em>將一直保持連接,直到請求結束。
即使我們預計我們的服務代碼不會耗盡連接池,OSIV的存在本身也可能導致整個應用程序無響應。
更糟糕的是,問題根源(慢速遠程服務)和症狀(數據庫連接池)之間完全無關。由於這種微弱的相關性,此類性能問題在生產環境中難以診斷。
4.3. 不必要的查詢
不幸的是,耗盡連接池並不是與OSIV相關的唯一性能問題。
由於<em Session</em>在整個請求生命週期內保持打開狀態,某些屬性導航可能會在事務上下文中觸發額外的、不希望的查詢。 甚至可能導致n+1查詢問題,更糟糕的是,我們可能直到在生產環境中才會注意到這一點。
更甚的是,<em Session</em>會在`自動提交模式下執行所有這些額外的查詢。 在自動提交模式下,每個SQL語句都會被視為一個事務並自動在執行後提交。 這會給數據庫帶來很大的壓力。
5. 謹慎選擇
無論 OSIV 是否是一種模式或反模式都無關緊要。 關鍵在於我們所處的現實情況。
如果我們在開發一個簡單的 CRUD 服務,那麼使用 OSIV 可能會有意義,因為我們可能永遠不會遇到那些性能問題。
另一方面,如果我們發現自己頻繁地調用大量的遠程服務,或者我們的事務上下文之外發生了很多事情,那麼完全禁用 OSIV 強烈建議。
如果不確定,最好從不使用 OSIV,因為我們稍後可以輕鬆地啓用它。另一方面,禁用一個已經啓用的 OSIV 可能會很麻煩,因為我們可能需要處理大量的 LazyInitializationExceptions。
總而言之,我們應該瞭解使用或忽略 OSIV 的權衡。
6. 替代方案
如果禁用OSIV,那麼我們應該以某種方式防止在處理惰性關聯時可能出現的惰性初始化異常。 在應對惰性關聯的眾多方法中,我們將在此列舉其中兩種。
6.1. 實體圖 (Entity Graphs)
當在 Spring Data JPA 中定義查詢方法時,可以使用 <em @EntityGraph</em> 註解來提前檢索實體的一部分:
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "permissions")
Optional<User> findByUsername(String username);
}在這裏,我們定義了一個臨時實體圖,以便急加載 權限 屬性,即使它默認情況下是一個懶加載的集合。
如果我們需要從同一個查詢中返回多個投影,那麼我們應該定義多個查詢,並使用不同的實體圖配置:
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "permissions")
Optional<User> findDetailedByUsername(String username);
Optional<User> findSummaryByUsername(String username);
}6.2. 使用 Hibernate.initialize() 的注意事項
有人可能會認為,與其使用實體圖,不如使用臭名昭著的 Hibernate.initialize() 來在需要時檢索懶加載關聯:
@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));
return user;
}他們可能會對此有所精通,並建議調用getPermissions()方法來觸發獲取過程:
Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> {
Set<String> permissions = u.getPermissions();
System.out.println("Permissions loaded: " + permissions.size());
});由於兩種方法均不推薦,因為它們會產生(至少)一個額外的查詢,除此之外,還需要檢索惰性關聯。也就是説,Hibernate 會生成以下查詢來檢索用户及其權限:
> select u.id, u.username from users u where u.username=?
> select p.user_id, p.permissions from user_permissions p where p.user_id=?
雖然大多數數據庫在執行第二個查詢時表現良好,但我們應該避免額外的網絡往返。
另一方面,如果使用實體圖或甚至 Fetch Joins,Hibernate 將僅使用一個查詢來獲取所有必要的數據。
> select u.id, u.username, p.user_id, p.permissions from users u
left outer join user_permissions p on u.id=p.user_id where u.username=?7. 結論
在本文中,我們重點關注了 Spring 以及其他一些企業級框架中一個頗具爭議的特性:在視圖中的 Open Session。首先,我們對該模式在概念和實現層面進行了瞭解。然後,我們從生產效率和性能的角度進行了分析。