本課時我們主要分析一個案例,那就是分庫分表后,我的應用崩潰了。
前面介紹了一種由于數據庫查詢語句拼接問題,而引起的一類內存溢出。下面將詳細介紹一下這個過程。
假設我們有一個用戶表,想要通過用戶名來查詢某個用戶,一句簡單的 SQL 語句即可:
```
select?*?from?user?where?fullname?=?"xxx"?and?other="other";
```
為了達到動態拼接的效果,這句 SQL 語句被一位同事進行了如下修改。他的本意是,當 fullname 或者 other 傳入為空的時候,動態去掉這些查詢條件。這種寫法,在 MyBaits 的配置文件中,也非常常見。
```
List<User>?query(String?fullname,?String?other)?{
????????StringBuilder?sb?=?new?StringBuilder("select?*?from?user?where?1=1?");
????????if?(!StringUtils.isEmpty(fullname))?{
????????????sb.append("?and?fullname=");
????????????sb.append("?\""?+?fullname?+?"\"");
????????}
????????if?(!StringUtils.isEmpty(other))?{
????????????sb.append("?and?other=");
????????????sb.append("?\""?+?other?+?"\"");
????????}
????????String?sql?=?sb.toString();
????????...
?}
```
大多數情況下,這種寫法是沒有問題的,因為結果集合是可以控制的。但隨著系統的運行,用戶表的記錄越來越多,當傳入的 fullname 和 other 全部為空時,悲劇的事情發生了,SQL 被拼接成了如下的語句:
```
select?*?from?user?where?1=1
```
數據庫中的所有記錄,都會被查詢出來,載入到 JVM 的內存中。由于數據庫記錄實在太多,直接把內存給撐爆了。
在工作中,由于這種原因引起的內存溢出,發生的頻率非常高。通常的解決方式是強行加入分頁功能,或者對一些必填的參數進行校驗,但不總是有效。因為上面的示例僅展示了一個非常簡單的 SQL 語句,而在實際工作中,這個 SQL 語句會非常長,每個條件對結果集的影響也會非常大,在進行數據篩選的時候,一定要小心。
#### 內存使用問題

拿一個最簡單的 Spring Boot 應用來說,請求會通過 Controller 層來接收數據,然后 Service 層會進行一些邏輯的封裝,數據通過 Dao 層的 ORM 比如 JPA 或者 MyBatis 等,來調用底層的 JDBC 接口進行實際的數據獲取。通常情況下,JVM 對這種數據獲取方式,表現都是非常溫和的。我們挨個看一下每一層可能出現的一些不正常的內存使用問題(僅限 JVM 相關問題),以便對平常工作中的性能分析和性能優化有一個整體的思路。
首先,我們提到一種可能,那就是類似于 Fastjson 工具所產生的 bug,這類問題只能通過升級依賴的包來解決,屬于一種極端案例。具體可參考這里
#### Controller 層
Controller 層用于接收前端查詢參數,然后構造查詢結果。現在很多項目都采用前后端分離架構,所以 Controller 層的方法,一般使用 @ResponseBody 注解,把查詢的結果,解析成 JSON 數據返回。
這在數據集非常大的情況下,會占用很多內存資源。假如結果集在解析成 JSON 之前,占用的內存是 10MB,那么在解析過程中,有可能會使用 20M 或者更多的內存去做這個工作。如果結果集有非常深的嵌套層次,或者引用了另外一個占用內存很大,且對于本次請求無意義的對象(比如非常大的 byte[] 對象),那這些序列化工具會讓問題變得更加嚴重。
因此,對于一般的服務,保持結果集的精簡,是非常有必要的,這也是 DTO(Data Transfer Object)存在的必要。如果你的項目,返回的結果結構比較復雜,對結果集進行一次轉換是非常有必要的。互聯網環境不怕小結果集的高并發請求,卻非常恐懼大結果集的耗時請求,這是其中一方面的原因。
#### Service 層
Service 層用于處理具體的業務,更加貼合業務的功能需求。一個 Service,可能會被多個 Controller 層所使用,也可能會使用多個 dao 結構的查詢結果進行計算、拼裝。
Service 的問題主要是對底層資源的不合理使用。舉個例子,有一回在一次代碼 review 中,發現了下面讓人無語的邏輯:
```
//錯誤代碼示例
int?getUserSize()?{
????????List<User>?users?=?dao.getAllUser();
????????return?null?==?users???0?:?users.size();
}
```
這種代碼,其實在一些現存的項目里大量存在,只不過由于項目規模和工期的原因,被隱藏了起來,成為內存問題的定時炸彈。
Service 層的另外一個問題就是,職責不清、代碼混亂,以至于在發生故障的時候,讓人無從下手。這種情況就更加常見了,比如使用了 Map 作為函數的入參,或者把多個接口的請求返回放在一個 Java 類中。
```
//錯誤代碼示例
Object?exec(Map<String,Object>?params){
????????String?q?=?getString(params,"q");
????????if(q.equals("insertToa")){
????????????String?q1?=?getString(params,"q1");
????????????String?q2?=?getString(params,"q2");
????????????//do?A
????????}else?if(q.equals("getResources")){
????????????String?q3?=?getString(params,"q3");
????????????//do?B
????????}
????????...
????????return?null;
}
```
這種代碼使用了萬能參數和萬能返回值,exec 函數會被幾十個上百個接口調用,進行邏輯的分發。這種將邏輯揉在一起的代碼塊,當發生問題時,即使使用了 Jstack,也無法發現具體的調用關系,在平常的開發中,應該嚴格禁止。
#### ORM 層
ORM 層可能是發生內存問題最多的地方,除了本課時開始提到的 SQL 拼接問題,大多數是由于對這些 ORM 工具使用不當而引起的。
舉個例子,在 JPA 中,如果加了一對多或者多對多的映射關系,而又沒有開啟懶加載、級聯查詢的時候就容易造成深層次的檢索,內存的開銷就超出了我們的期望,造成過度使用。
另外,JPA 可以通過使用緩存來減少 SQL 的查詢,它默認開啟了一級緩存,也就是 EntityManager 層的緩存(會話或事務緩存),如果你的事務非常的大,它會緩存很多不需要的數據;JPA 還可以通過一定的配置來完成二級緩存,也就是全局緩存,造成更多的內存占用。
一般,項目中用到緩存的地方,要特別小心。除了容易造成數據不一致之外,對堆內內存的使用也要格外關注。如果使用量過多,很容易造成頻繁 GC,甚至內存溢出。
JPA 比起 MyBatis 等 ORM 擁有更多的特性,看起來容易使用,但精通門檻卻比較高。
這并不代表 MyBatis 就沒有內存問題,在這些 ORM 框架之中,存在著非常多的類型轉換、數據拷貝。
舉個例子,有一個批量導入服務,在 MyBatis 執行批量插入的時候,竟然產生了內存溢出,按道理這種插入操作是不會引起額外內存占用的,最后通過源碼追蹤到了問題。
這是因為 MyBatis 循環處理 batch 的時候,操作對象是數組,而我們在接口定義的時候,使用的是 List;當傳入一個非常大的 List 時,它需要調用 List 的 toArray 方法將列表轉換成數組(淺拷貝);在最后的拼裝階段,使用了 StringBuilder 來拼接最終的 SQL,所以實際使用的內存要比 List 多很多。
事實證明,不論是插入操作還是查詢動作,只要涉及的數據集非常大,就容易出現問題。由于項目中眾多框架的引入,想要分析這些具體的內存占用,就變得非常困難。保持小批量操作和結果集的干凈,是一個非常好的習慣。
#### 分庫分表內存溢出
* [ ] 分庫分表組件
如果數據庫的記錄非常多,達到千萬或者億級別,對于一個傳統的 RDBMS 來說,最通用的解決方式就是分庫分表。這也是海量數據的互聯網公司必須面臨的一個問題。

根據切入的層次,數據庫中間件一般分為編碼層、框架層、驅動層、代理層、實現層 5 大類。典型的框架有驅動層的 sharding-jdbc 和代理層的 MyCat。
MyCat 是一個獨立部署的 Java 服務,它模擬了一個 MySQL 進行請求的處理,對于應用來說使用是透明的。而 sharding-jdbc 實際上是一個數據庫驅動,或者說是一個 DataSource,它是作為 jar 包直接嵌入在客戶端應用的,所以它的行為會直接影響到主應用。
這里所要說的分庫分表組件,就是 sharding-jdbc。不管是普通 Spring 環境,還是 Spring Boot 環境,經過一系列配置之后,我們都可以像下面這種方式來使用 sharding-jdbc,應用層并不知曉底層實現的細節:
```
@Autowired
private?DataSource?dataSource;
```
我們有一個線上訂單應用,由于數據量過多的原因,進行了分庫分表。但是在某些條件下,卻經常發生內存溢出。
* [ ] 分庫分表的內存溢出
一個最典型的內存溢出場景,就是在訂單查詢中使用了深分頁,并且在查詢的時候沒有使用“切分鍵”。使用前面介紹的一些工具,比如 MAT、Jstack,最終追蹤到是由于 sharding-jdbc 內部實現所引起的。
這個過程也是比較好理解的,如圖所示,訂單數據被存放在兩個庫中。如果沒有提供切分鍵,查詢語句就會被分發到所有的數據庫中,這里的查詢語句是 limit 10、offset 1000,最終結果只需要返回 10 條記錄,但是數據庫中間件要完成這種計算,則需要 (1000+10)*2=2020 條記錄來完成這個計算過程。如果 offset 的值過大,使用的內存就會暴漲。雖然 sharding-jdbc 使用歸并算法進行了一些優化,但在實際場景中,深分頁仍然引起了內存和性能問題。

下面這一句簡單的 SQL 語句,會產生嚴重的后果:
```
select?*?from?order??order?by?updateTime?desc?limit?10?offset?10000
```
這種在中間節點進行歸并聚合的操作,在分布式框架中非常常見。比如在 ElasticSearch 中,就存在相似的數據獲取邏輯,不加限制的深分頁,同樣會造成 ES 的內存問題。
另外一種情況,就是我們在進行一些復雜查詢的時候,發現分頁失效了,每次都是取出全部的數據。最后根據 Jstack,定位到具體的執行邏輯,發現分頁被重寫了。
```
private?void?appendLimitRowCount(final?SQLBuilder?sqlBuilder,?final?RowCountToken?rowCountToken,?final?int?count,?final?List<SQLToken>?sqlTokens,?final?boolean?isRewrite)?{
????????SelectStatement?selectStatement?=?(SelectStatement)?sqlStatement;
????????Limit?limit?=?selectStatement.getLimit();
????????if?(!isRewrite)?{
????????????sqlBuilder.appendLiterals(String.valueOf(rowCountToken.getRowCount()));
????????}?else?if?((!selectStatement.getGroupByItems().isEmpty()?||?!selectStatement.getAggregationSelectItems().isEmpty())?&&?!selectStatement.isSameGroupByAndOrderByItems())?{
????????????sqlBuilder.appendLiterals(String.valueOf(Integer.MAX_VALUE));
????????}?else?{
????????????sqlBuilder.appendLiterals(String.valueOf(limit.isNeedRewriteRowCount()???rowCountToken.getRowCount()?+?limit.getOffsetValue()?:?rowCountToken.getRowCount()));
????????}
????????int?beginPosition?=?rowCountToken.getBeginPosition()?+?String.valueOf(rowCountToken.getRowCount()).length();
????????appendRest(sqlBuilder,?count,?sqlTokens,?beginPosition);
????}
```
如上代碼,在進入一些復雜的條件判斷時(參照 SQLRewriteEngine.java),分頁被重置為 Integer.MAX_VALUE。
#### 總結
本課時以 Spring Boot 項目常見的分層結構,介紹了每一層可能會引起的內存問題,我們把結論歸結為一點,那就是保持輸入集或者結果集的簡潔。一次性獲取非常多的數據,會讓中間過程變得非常不可控。最后,我們分析了一個驅動層的數據庫中間件,以及對內存使用的一些問題。
很多程序員把這些耗時又耗內存的操作,寫了非常復雜的 SQL 語句,然后扔給最底層的數據庫去解決,這種情況大多數認為換湯不換藥,不過是把具體的問題沖突,轉移到另一個場景而已。
#### 課后問答
* 1、因此,對于一般的服務,保持結果集的精簡,是非常有必要的,這也是DTO(DataTransferObject)存在的必要。如果你的項目,返回的結果結構比較復雜,對結果集進行一次轉換是非常有必要的。---李老師,請問這句話具體怎么理解,可以說的詳細點么
答案: 這個問題了解DTO就可以迎刃而解了。比如你從數據庫中查詢出了兩個非常大的數據集合,但返回給頁面的只需要里面的一些子集,就需要新建一個簡潔的對象,然后拷貝部分數據過去。JSON解析和忘了傳輸的時候,最終的結果集合就小的多。
* 2、Service層//錯誤代碼示例 int getUserSize() 不合理的地方在哪里?沒看明白。什么是“深分頁”?
答案: dao獲取user列表的長度是不可預知的,可以使用select count語句。深分頁舉例:有一億條數據,你要看orderby的第5千萬條數據后的10條數據。
- 前言
- 開篇詞
- 基礎原理
- 第01講:一探究竟:為什么需要 JVM?它處在什么位置?
- 第02講:大廠面試題:你不得不掌握的 JVM 內存管理
- 第03講:大廠面試題:從覆蓋 JDK 的類開始掌握類的加載機制
- 第04講:動手實踐:從棧幀看字節碼是如何在 JVM 中進行流轉的
- 垃圾回收
- 第05講:大廠面試題:得心應手應對 OOM 的疑難雜癥
- 第06講:深入剖析:垃圾回收你真的了解嗎?(上)
- 第06講:深入剖析:垃圾回收你真的了解嗎?(下)
- 第07講:大廠面試題:有了 G1 還需要其他垃圾回收器嗎?
- 第08講:案例實戰:億級流量高并發下如何進行估算和調優
- 實戰部分
- 第09講:案例實戰:面對突如其來的 GC 問題如何下手解決
- 第10講:動手實踐:自己模擬 JVM 內存溢出場景
- 第11講:動手實踐:遇到問題不要慌,輕松搞定內存泄漏
- 第12講:工具進階:如何利用 MAT 找到問題發生的根本原因
- 第13講:動手實踐:讓面試官刮目相看的堆外內存排查
- 第14講:預警與解決:深入淺出 GC 監控與調優
- 第15講:案例分析:一個高死亡率的報表系統的優化之路
- 第16講:案例分析:分庫分表后,我的應用崩潰了
- 進階部分
- 第17講:動手實踐:從字節碼看方法調用的底層實現
- 第18講:大廠面試題:不要搞混 JMM 與 JVM
- 第19講:動手實踐:從字節碼看并發編程的底層實現
- 第20講:動手實踐:不為人熟知的字節碼指令
- 第21講:深入剖析:如何使用 Java Agent 技術對字節碼進行修改
- 第22講:動手實踐:JIT 參數配置如何影響程序運行?
- 第23講:案例分析:大型項目如何進行性能瓶頸調優?
- 彩蛋
- 第24講:未來:JVM 的歷史與展望
- 第25講:福利:常見 JVM 面試題補充