Java Web應用高併發性能優化方案彙總

背景


公司開發的一個門戶系統運行幾年了,最近因爲客戶的組織機構調整,要大幅增加用戶數,於是開始了一場對系統併發性能進行調優的艱難之旅。

本文記錄一個傳統Java Web系統性能調優過程的方方面面,希望將來能成爲此類工作的指南和索引,從中跳轉到各個零碎的知識點。因爲涉及點非常多,這裏只能做彙總記錄,求全不求精,每個知識點的具體細節,需要自行到網上查找,文中也給出了一些不錯的參考鏈接。寫作不易,給出的鏈接地址都是經過篩選的,有些實在找不到滿意的就只好自己寫,因此這篇文章如果給大家帶來了一些幫助,別忘了點個贊哦!
本文未涉及複雜的架構變化(因爲不需要啊!),不適用於大型網站。想了解大型網站架構優化的,瞅瞅這個:大型網站架構演化

系統現狀


系統現狀是這樣的:

  • 服務器:1臺應用服務器,1臺Oracle數據庫。都是Windows系統。
  • 應用:有兩個,分別是提供單點登錄功能的CAS服務和門戶系統,各自運行在各自的Tomcat中,但都在一臺應用服務器上。系統代碼老舊,沒考慮過優化。
  • 在線用戶數:100左右
  • 與外部系統接口:作爲門戶系統,與多個第三方系統有後臺接口,抓取這些系統的數據,展現在門戶系統首頁上。這些接口需要在用戶登錄後定時刷新顯示,

在以上背景下,系統經LoadRunner壓力測試,併發訪問能力慘不忍睹。客戶要求達到1000併發(不是1000在線!),於是開始了艱難的優化之旅。

優化過程


概括說來,爲支持高併發的核心優化點是提高後端處理性能和前後端之間的通信效率。提高前端(瀏覽器)性能的方法是沒有幫助的。總的調優方向可以概括爲:

  • 增加服務器處理資源
  • 提高服務器運行效率
  • 減少前後端交互次數
  • 減少總通訊流量

因系統運行多年,爲降低風險,採用了儘量不修改代碼、不增加新的獨立架構組件的原則。最終,我們沿着以下的路線來開始這場優化之旅:
一、 應用系統調優
二、 使用集羣
三、 網絡和部署方式調優

一、應用系統調優


準備:調優分析工具

當系統響應緩慢時,如何準確找到瓶頸點在哪?這裏隆重介紹JDK自帶工具VisuslVM,利用它可以非常容易地找出運行最耗時的地方,精確到函數級別哦!具體用法,可以參考下面這篇文檔:性能分析神器VisualVM。在我的實踐中,最有用的就是使用其中的Sampler抽樣器功能,找出最耗時的函數,做出針對性優化。

另一個分析神器是阿里出品的Druid連接池。是的,你沒看錯,一個連接池竟然自帶了非常完整的系統監控功能,可以在線查看和分析數據庫訪問、HTTP請求的實時和統計數據,實爲居家開發必備之品。參考鏈接:http://www.cnblogs.com/han-1034683568/p/6730869.html

1. 使用緩存

一個Web系統的典型處理流程是接受HTTP請求、查詢數據庫、根據數據渲染頁面、返回頁面給用戶。這個過程有兩個地方可以運用緩存:

  • 數據庫緩存:緩存查詢數據庫的結果;
  • 頁面緩存:緩存渲染的頁面;

(1)數據庫緩存
大多數情況下系統高併發的最大瓶頸是數據庫。數據庫因爲其內部的事務、鎖等機制,天生難以達到很高的併發處理能力。我們需要使用應用層緩存來降低應用程序與數據庫的交互次數。

可以通過Spring框架中的@Cacheable@CacheEvict來操作緩存(其實不僅僅可以用於數據庫方面,Service層的接口都可以)。這方面文章很多,這裏不介紹了,只提醒一個坑:Spring默認的緩存Key的生成策略比較簡單,如果有兩個方法使用的參數一致,可能會產生衝突。所以一定要自定義Key生成策略。這篇文章可以參考一下: https://www.jianshu.com/p/2a584aaafad3。

(2)頁面緩存
對企業門戶類的系統,頁面的變動頻率並不高(不要拿互聯網門戶來比啊!),對用戶來說也可以接受一定的延時,因此直接緩存最終渲染後的頁面,跳過後臺所有獲取數據、組裝頁面的過程,能得到極高的響應速度。配置過程很簡單,只要設置好緩存參數,再添加一個Web過濾器,不需要改動代碼,是不是很美好?具體可參考ehcache實現頁面整體緩存和頁面局部緩存


緩存選型上,我們使用了Ehcache這種嵌入式的輕量級緩存,從而不改變系統總體架構。當然也可以使用其他的,只是要注意,如果使用嵌入式的,最好能支持集羣。Spring已經對緩存接口做了抽象,只要寫好適配器,就可以通過統一的接口來使用。

2. 優化數據庫連接

根據併發數量的要求,需要調節數據庫連接池的參數。我們在門戶系統中使用的是Druid連接池,參數可以參考網上文章,根據需求和監測到的結果來調整。可參考: https://www.jianshu.com/p/e75d73129f51
下面列出了我們的部分參數:

<!-- 配置初始化大小、最小、最大 -->
<property name="initialSize" value="100" />
<property name="minIdle" value="100" />
<property name="maxActive" value="500" />
<!-- 是否關閉未關閉的連接 -->
<property name="removeAbandoned" value="true" />
<property name="removeAbandonedTimeout" value="10" />
<!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連接,單位是毫秒 -->
<property name="timeBetweenEvictionRunsMillis" value="10000" />
<!-- 配置一個連接在池中最小生存的時間,單位是毫秒 -->
<property name="minEvictableIdleTimeMillis" value="30000" />
<!-- 打開PSCache,並且指定每個連接上PSCache的大小。Oracle應打開 -->
<property name="poolPreparedStatements" value="true" />
<property name="maxPoolPreparedStatementPerConnectionSize" value="20" />    
<!-- 配置監控統計攔截的filters -->
<property name="filters" value="stat" />

當然,上面只是應用程序層的調參,對數據庫本身還需要通過命令來修改最大連接數限制,並且這個限制需要與應用層的參數配套。
這裏我說一下調優經驗:
(1) 務必開啓Druid連接池的監控統計功能:通過Druid的監控頁面可以在壓測時實時查看數據庫連接數、最慢SQL、SQL執行次數、HTTP請求次數、最慢HTTP請求等重要信息,從而爲優化程序代碼、設置最大連接數上限等行動提供參考。
(2) 應用程序連接池和數據庫最大連接數之間要配套:如果有多個應用訪問同一個數據庫,需要注意他們的連接池上限之和,防止出現每個連接池本身未滿,加起來壓垮了數據庫的情況。一般來說,先根據併發用戶數來確定數據庫的連接上限,再根據該上限來分配各個連接池的額度。不過,對單次用戶訪問,各個應用通常不會同時連接數據庫,所以連接池上限之和是可以大於數據庫上限的。測試時監控數據庫連接數的變化情況,會幫助你搞清楚你係統的併發用戶數與數據庫連接數之間的大致關係。
(3) 壓測時根據錯誤日誌判斷哪裏需要調參:簡單說,如果報錯堆棧的最上面是連接池的Class信息,則說明是連接池自身報錯,需要調連接池參數;如果最上面是JDBC Class的錯誤,則說明已經到達了實際訪問數據庫的地方,很大可能是數據庫出錯,需要調整數據庫參數,但也有可能是因爲連接池參數不當間接造成的數據庫錯誤。分析時可能需要去看數據庫運行日誌。

3. 優化日誌輸出

我們的應用系統有兩種類型日誌:

  • 用戶登錄等重要事件的操作日誌,存在數據庫操作日誌表中
  • 系統運行時通過Logger打印到文件的運行日誌

第一種日誌在壓測時嚴重影響性能甚至造成系統崩潰。其實不僅僅是日誌,因爲數據庫的鎖機制,只要是高併發地往同一張表寫入數據,性能都會急劇下降甚至報錯。此時需要引入緩存:先把要寫入的數據緩存起來,然後用一個後臺工作線程定時或定量觸發方式把緩存數據通過Batch SQL的方式批量寫入數據庫。緩存可以起到削峯的作用,缺點是寫入滯後、異常情況下丟失數據。對本系統的日誌而言,這些問題可以接受。如果需要高實時性、高可靠性,那就考慮使用Redis、ElasticSearch之類的外部組件,優化架構吧。

第二種日誌,如果用戶一個動作產生2行日誌,併發1000就意味着每秒鐘要寫入2000行文件,說大不大說小不小,畢竟存儲設備也是有IOPS上限的。爲了榨取最大性能,可以考慮把日誌級別調到WARN以上,減少日誌輸出量。

4. 程序代碼優化

應用程序代碼優化是個細活、工夫活,這裏分幾個層面來說。
(1)前端優化
先看這個彙總吧:Web前端性能優化。需要提醒的是,這裏面大多數需要修改代碼,而且很大一部分只是優化在瀏覽器中的顯示速度來提升用戶體驗,對壓力測試成績不會有什麼提高。

不改代碼且有明顯效果的,是在Tomcat上開啓gzip壓縮。雖然會提高CPU佔用率,但能顯著降低網絡流量,提高併發訪問能力。配置是否成功,看一下瀏覽器調試窗口中,HTTP Response中的Content-Encoding是不是GZIP就知道了。

我們在本次優化中偷懶只使用gzip壓縮這一條,其他需要改代碼的方式,以後再說吧,美其名曰保持系統穩定性:-)

值得一提的是,在使用LoadRunner做壓力測試時,有一些選項開關是控制是否模仿瀏覽器行爲緩存數據的。這篇文章寫的比較仔細,可以參考看一下。如果LoadRunner中不啓用緩存,則測試成績相當於所有用戶都是第一次訪問系統,需要下載所有數據。而真實高併發場景中,用戶的瀏覽器已經有緩存數據,不需要再次請求。因此測試成績會比實際要差。

(2)後端優化
Java方面的親身經歷,曾經把一個用String大量循環拼字符串的代碼改成用StringBuffer,響應時間從5秒降到了不足1秒。SQL方面,最常見錯誤是在WHERE 語句中對索引字段進行計算、類型轉換等操作,導致索引完全沒發揮作用。

Java和SQL的性能優化,網上文章多的很,就不贅述了。使用前面介紹的VisualVM和Druid連接池監控進行分析,從最慢的地方開始,耐心調優吧。
(3)數據架構優化:緩存常用穩定數據
哪些數據是常用穩定數據?組織機構、用戶、角色權限、數據字典等。

舉個例子,每個用戶登錄時都需要到數據庫去查詢賬號密碼是否匹配。前面說的Spring緩存機制只能提高同一個用戶再次登錄時的查詢性能,不能提高多個不同用戶同時第一次登錄時的性能。因用戶表很少發生變化,數據量也不大,故可以整體放到緩存中,需要時從緩存查詢,帶來數量級的性能提升。系統在啓動時加載數據,在有改動時則更新緩存。

如果系統是單實例部署,出於簡單性考慮,緩存可以直接使用JVM內存。如果是集羣部署,此類數據最好放在JVM外部獨立緩存或支持集羣的JVM嵌入式緩存,以確保數據一致性。如果非要用JVM內存(也太懶了吧?),只要對數據的實時性要求不高,比如允許修改的數據在5分鐘後才生效,則可以在集羣的每個實例中用後臺線程定時刷新數據,更新到內存中,不過此時無法保證任一瞬間每臺服務器看到的數據是一樣的,對業務邏輯是否有影響,要具體情況具體分析。

(4)第三方接口調用優化
原系統邏輯是這樣的:用戶在加載門戶系統首頁時,會通過Ajax請求加載第三方系統數據。此時後臺會順序調用若干第三方系統的接口,並返回合併後的數據。同時首頁有定時自動刷新機制,確保數據變動能及時反映。
這裏的主要問題有:

  • 一次查詢多個第三方接口本身是個較慢的過程,頁面請求要等待後臺查詢完成,響應慢,用戶體驗不佳,壓力測試成績差;
  • 如果過程任何一個環節出錯,頁面上就缺失數據甚至爲空;
  • 第三方系統無法承受高併發的查詢壓力;

我們可以採取以下手段進行優化:

  • 引入緩存,解耦客戶端請求和第三方接口調用過程
    建立一個緩存區,後臺調用第三方接口並把結果放在緩存中,客戶端請求直接從緩存獲取數據。
    這樣一來,用戶請求不用等待後臺調用,響應時間大大縮短。後臺通過定時器和任務隊列機制,把在線用戶的數據從各個第三方系統拉過來放入緩存,這個過程慢就慢一點,反正用戶感覺不到,如果一個週期沒執行完任務,那就跳過下一個定時任務就好了,避免雪崩效應壓垮第三方系統。
    聰明的讀者可能立刻會想到,如果用戶剛登錄,緩存裏還沒數據,那不是什麼都看不到嗎?的確存在這個問題。後臺定時拉數據是採用隊列方式的,我們可以在用戶登錄時產生一個優先級高於後臺定時任務的任務,插隊優先獲取數據,從而讓用戶及時看到。不過這種方式對用戶高併發同時登錄的場景不適用,還需要後面的方法解決。
    緩存的內存佔用取決於用戶數和第三方數據量。通常門戶系統展示的是一些待辦通知類的數據,1萬人*每人100條數據=100萬條數據,對現在的硬件能力是小菜一碟。
  • 化零爲整,降低頻度
    以用戶爲單位獲取第三方數據,在高併發下必然導致調用過於頻繁、第三方系統壓力過高,很容易崩潰。可以考慮化零爲整,把以用戶爲單位的查詢合併起來,變爲批量用戶查詢,把查詢結果以用戶爲單位放到緩存中。當然,如果用戶數太多,每次查詢所有用戶的數據,這個過程會非常緩慢甚至不可行。可以通過分組,把重點用戶(例如領導)放在一起且優先查詢,其他的分成另一組或幾組,頻率降低,慢就慢一點也無妨。要徹底解決,還需要考慮其他方法。
  • 減少每次的數據量
    每次獲取全量數據,大部分都沒有變化,太浪費地球資源了。如果每次調用接口都帶上上一次調用的時間戳參數,就可以讓第三方系統只提供增量數據,從而減少傳輸量。不過這需要第三方系統接口支持纔行。對數據的修改、刪除,還需要有約定標誌便於更新緩存。
  • 拉數據改爲推數據
    最高效的辦法是讓第三方在數據變動時主動推送數據給我們的接口,接口負責更新到緩存,這樣理論上把數據流量降到了最低。不過這還是需要第三方系統支持,可遇不可求。此外,還需要去解決系統剛啓動時的數據從何而來,要增加接口,這裏不展開說了。

如果沒有緩存和解耦的理念,上面這些方案全都無法實現,這充分說明了它們在系統架構中的重要性。

5. 數據庫設計優化

架構不變情況下,對錶添加查詢字段的索引、把最近數據和歷史數據分表存儲,這些常規方法都有明顯效果。還不行的話,考慮讀寫分離等架構升級方案吧。

6. Tomcat運行參數優化

默認的Tomcat運行參數不滿足當前併發需求,要進行調整。隨便搜了幾篇供參考:
Tomcat8.0 基本參數調優配置
聊下併發和Tomcat線程數(Updated)
這次優化中,我們主要調的參數是Server.xml中的這兩個:

# 最大併發數 
maxThreads="2000"
# 指定當所有可以使用的處理請求的線程數都被使用時,可以放到處理隊列中的請求數,超過這個數的請求將不予處理
acceptCount="2000"

注意,很多網上文章提到的maxSpareThreads這個參數,從Tomcat 7之後就沒有了,不用再費心調了。

此外,因高併發帶來的系統內存資源佔用提高,需要根據實際測算結果來調整JVM參數 ,修改Catalina.bat/Catalina.sh中的JAVA_OPTS。比如我們設置的參數是:-Xms8096m -Xmx16192m

二、Tomcat集羣


通過Apache + Tomcat集羣的方式,提高系統承載能力。這方面文章很多,可以參考:Apache+Tomcat集羣負載均衡的兩種session處理方式

這裏說兩個要點:

  1. 統一Session:集羣可以實現負載均衡,但不一定能實現HA高可用(即一臺服務器宕機不影響用戶繼續使用)。要實現高可用,就必須要通過Session複製、分佈式Session等方法實現Session的高可用。上面給出的鏈接中有使用Ehcache實現的例子。
  2. 統一靜態數據容器:放在JVM內存中的靜態容器(例如用一個全局HashMap存儲當前在線用戶列表),在集羣后因爲應用進程分成了多個,會導致每個應用服務器上的該容器數據不一致。因Session粘滯,A服務器上的用戶看到的列表是A服務器上的用戶列表,B上的用戶看到的則是B上的用戶列表,連在線用戶總數都無法統計。因此做集羣前,必須對系統代碼中這些全局數據容器進行摸排分析,必要的話把這些數據放到可保證唯一性的容器中,如數據庫、支持集羣的嵌入式緩存(例如Ehcache)、外部獨立緩存服務器中。也就是說,從單機遷移到集羣,可能是需要修改程序的!!!。可以在代碼中搜索 static Mapstatic List等字樣來找到這些地雷。

三、網絡和部署方式調優


1. 操作系統TCP連接數調優(僅針對Windows服務器)

講真,我也不知道這是不是必要的,但很多帖子都說,就這麼做了。也找到一篇微軟官方文章說這個有用。

Windows系統可能限制了TCP端口連接數,併發高了就無法連接,需要修改下面的註冊表項:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\TCPIP\Parameters

修改端口數MaxUserPort爲十進制的65534,TcpTimedWaitDelay爲5。如果沒有的話先創建。

2. Apache參數調優

我們用的服務器是Windows,在Apache上設置參數如下:

<IfModule mpm_winnt_module>
    ThreadLimit 10000
    ThreadsPerChild 10000
    MaxRequestsPerChild 0
</IfModule>
KeepAlive  On 
KeepAliveTimeout 30

解釋如下:

  • 使用Windows服務器要啓用mpm_winnt_module模塊來控制併發。該模塊的參數含義最好看Apache官方文檔,清晰明瞭。
  • ThreadLimit:Apache支持多少客戶端同時連接的全局上限。Windows下默認值是1920,不滿足就按需調大吧。但Apache硬編碼了上限,在Windows上不能超過15000。
  • ThreadsPerChild:每個子進程的最大線程數。在Windows上Apache只有一個子進程來處理HTTP請求,所以這個數字設置成最大可能數就行了,不能超過ThreadLimit,默認值是64。如果設置過大超出硬件處理能力,可能會導致服務器不穩定。
  • MaxRequestsPerChild:每個子進程處理的請求數上限,如果達到了,則子進程會被Apache殺掉重新生成。設置成0(默認值)表示沒有上限,進程永遠不會被殺死重建。在Apache 2.3.9之後,這個指令的名字改成了MaxConnectionsPerChild,但原名也可以用。爲什麼要設置這個參數呢?官方解釋是爲了防止進程出現偶發性內存泄露,所以搞了個定時重生。然而Windows上Apache只有一個子進程,殺掉重啓的這段時間應該是會影響用戶訪問體驗的(所有線程都沒了啊啊啊,要重新創建啊啊啊),所以我設置成了0,阿彌陀佛上帝保佑不要出問題。
  • KeepAlive:設置HTTP連接用完後不立刻關閉,也就是常說的HTTP長連接。該指令對性能影響極大,一定要設置成On。
  • KeepAliveTimeout:持久連接保持的時間,默認5。太短則影響性能,太長則浪費資源。

3. 靜態資源代理(又稱動靜分離)

在前面集羣配置後,Apache負責接受用戶請求,並分發給後面的Tomcat服務器。Tomcat是一個Java Web應用服務器,其處理靜態資源的效率不高,最好讓Apache來接管提供靜態資源。這樣做也減少了提供資源的過程環節(原來要經過Apache和Tomcat兩步,現在只經過Apache),能提高響應速度。步驟如下:

  1. 在系統源碼中,儘可能把html、js、css、圖片等靜態資源,集中放在一個請求路徑下,比如http://…/static。這也是一個良好的開發習慣;
  2. 在Apache服務器上建一個本地目錄,用來存放上面的靜態資源文件。每次在Tomcat中發佈新版本時,也同時往該目錄拷貝一份;
  3. 在Apache的配置文件中,通過Alias指令,把/static路徑映射到第2步建立的本地目錄。因爲配置集羣時已經通過ProxyPass指令把所有請求轉給了Tomcat,所以還要通過ProxyPassMatch指令屏蔽這個特定路徑的轉發。
  4. Apache直接提供的靜態資源也需要進行gzip壓縮以提高性能,參考Apache啓用mod_deflate的gzip壓縮。JPG圖片就沒必要壓縮了哈!
  5. 打完收工。

下面是我們的配置實例:

#gzip壓縮
LoadModule filter_module modules/mod_filter.so
LoadModule deflate_module modules/mod_deflate.so
LoadModule headers_module modules/mod_headers.so
<IfModule mod_deflate.c>
    SetOutputFilter DEFLATE
    AddOutputFilterByType DEFLATE text/plain
    AddOutputFilterByType DEFLATE text/html
    #...略
</IfModule>
<IfModule alias_module>
    #靜態資源URL映射到本地
    Alias /portal/static "D:/static"
</IfModule>
#排除靜態資源的反向代理
ProxyPassMatch ^/portal/static !

總結


總算寫完了,累死我了,容我喘口氣先…

經過艱苦卓絕的優化、調試、問題分析、再優化,總算是有了回報:在沒有對架構和代碼做大幅修改,僅使用兩臺應用服務器的前提下,併發處理能力大概提高了20倍,已經接近客戶要求。後面可以通過增加服務器、優化瓶頸代碼來繼續提高,還在繼續努力中。加油!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章