高吞吐量系統設計優化建議

developerworks發表於2015-08-08

高吞吐量系統

舉一個例子,我們做專案需要安排計劃,每一個模組可以由多人同時並行做多項任務,也可以一個人或者多個人序列工作,但始終會有一條關鍵路徑,這條路徑就是專案的工期。系統一次呼叫的響應時間跟專案計劃一樣,也有一條關鍵路徑,這個關鍵路徑是就是系統影響時間。關鍵路徑由 CPU 運算、IO、外部系統響應等等組成。

對於一個系統的使用者來說,從使用者點選一個按鈕、連結或發出一條指令開始,到系統把結果以使用者希望的形式展現出來為終止,整個過程所消耗的時間是使用者對這個軟體效能的直觀印象,也就是我們所說的響應時間。當響應時間較短時,使用者體驗是很好的,當然使用者體驗的響應時間包括個人主觀因素和客觀響應時間。在設計軟體時,我們就需要考慮到如何更好地結合這兩部分達到使用者最佳的體驗。如:使用者在大資料量查詢時,我們可以將先提取出來的資料展示給使用者,在使用者看的過程中繼續進行資料檢索,這時使用者並不知道我們後臺在做什麼,使用者關注的是使用者操作的響應時間。

我們經常說的一個系統吞吐量,通常由 QPS(TPS)、併發數兩個因素決定,每套系統這兩個值都有一個相對極限值,在應用場景訪問壓力下,只要某一項達到系統最高值,系統的吞吐量就上不去了,如果壓力繼續增大,系統的吞吐量反而會下降,原因是系統超負荷工作,上下文切換、記憶體等等其它消耗導致系統效能下降,決定系統響應時間要素。

緩衝 (Buffer)

緩衝區是一塊特定的記憶體區域,開闢緩衝區的目的是通過緩解應用程式上下層之間的效能差異,提高系統的效能。在日常生活中,緩衝的一個典型應用是漏斗。緩衝可以協調上層元件和下層元件的效能差,當上層元件效能優於下層元件時,可以有效減少上層元件對下層元件的等待時間。基於這樣的結構,上層應用元件不需要等待下層元件真實地接受全部資料,即可返回操作,加快了上層元件的處理速度,從而提升系統整體效能。

使用 BufferedWriter 進行緩衝

BufferedWriter 就是一個緩衝區用法,一般來說,緩衝區不宜過小,過小的緩衝區無法起到真正的緩衝作用,緩衝區也不宜過大,過大的緩衝區會浪費系統記憶體,增加 GC 負擔。儘量在 I/O 元件內加入緩衝區,可以提高效能。一個緩衝區例子程式碼如清單 1 所示。

清單 1. 加上緩衝區之前示例程式碼
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;

import javax.swing.JApplet;

public class NoBufferMovingCircle extends JApplet implements Runnable{
 Image screenImage = null;
 Thread thread;
 int x = 5;
 int move = 1;

 public void init(){
 screenImage = createImage(230,160);
 }

 public void start(){
 if(thread == null){
 thread = new Thread(this);
 thread.start();
 }
 }

@Override
public void run() {
// TODO Auto-generated method stub
try{
System.out.println(x);
while(true){
x+=move;
System.out.println(x);
if((x>105)||(x<5)){
move*=-1;
}
repaint();
Thread.sleep(10);
}
}catch(Exception e){

}
}

public void drawCircle(Graphics gc){
Graphics2D g = (Graphics2D) gc;
g.setColor(Color.GREEN);
g.fillRect(0, 0, 200, 100);
g.setColor(Color.red);
g.fillOval(x, 5, 90, 90);
}

public void paint(Graphics g){
g.setColor(Color.white);
g.fillRect(0, 0, 200, 100);
drawCircle(g);
}

}

程式可以完成紅球的左右平移,但是效果較差,因為每次的介面重新整理都涉及圖片的重新繪製,這較為費時,因此,畫面的抖動和白光效果明顯。為了得到更優質的顯示效果,可以為它加上緩衝區。程式碼如清單 2 所示。

清單 2. 加上緩衝區之後示例程式碼
import java.awt.Color;
import java.awt.Graphics;

public class BufferMovingCircle extends NoBufferMovingCircle{
 Graphics doubleBuffer = null;//緩衝區

 public void init(){
 super.init();
 doubleBuffer = screenImage.getGraphics();
 }

 public void paint(Graphics g){//使用緩衝區,優化原有的 paint 方法
 doubleBuffer.setColor(Color.white);//先在記憶體中畫圖
 doubleBuffer.fillRect(0, 0, 200, 100);
 drawCircle(doubleBuffer);
 g.drawImage(screenImage, 0, 0, this);
 }
}

使用 Buffer 進行 I/O 操作

除 NIO 外,使用 Java 進行 I/O 操作有兩種基本方式:

  • 使用基於 InputStream 和 OutputStream 的方式;
  • 使用 Writer 和 Reader。

無論使用哪種方式進行檔案 I/O,如果能合理地使用緩衝,就能有效地提高 I/O 的效能。

下面顯示了可與 InputStream、OutputStream、Writer 和 Reader 配套使用的緩衝元件。

OutputStream-FileOutputStream-BufferedOutputStream

InputStream-FileInputStream-BufferedInputStream

Writer-FileWriter-BufferedWriter

Reader-FileReader-BufferedReader

使用緩衝元件對檔案 I/O 進行包裝,可以有效提高檔案 I/O 的效能。

清單 3. 示例程式碼
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;

public class StreamVSBuffer {
 public static void streamMethod() throws IOException{
 try {
long start = System.currentTimeMillis();
//請替換成自己的檔案
 DataOutputStream dos = new DataOutputStream(
                        new FileOutputStream("C://StreamVSBuffertest.txt"));
for(int i=0;i<10000;i++){
dos.writeBytes(String.valueOf(i)+"/r/n");//迴圈 1 萬次寫入資料
}
dos.close();
DataInputStream dis = new DataInputStream(new FileInputStream("C://StreamVSBuffertest.txt"));
while(dis.readLine() != null){

}
dis.close();
 System.out.println(System.currentTimeMillis() - start);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

 }

 public static void bufferMethod() throws IOException{
 try {
 long start = System.currentTimeMillis();
 //請替換成自己的檔案
 DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(
                                        new FileOutputStream("C://StreamVSBuffertest.txt")));
 for(int i=0;i<10000;i++){
 dos.writeBytes(String.valueOf(i)+"/r/n");//迴圈 1 萬次寫入資料
 }
 dos.close();
 DataInputStream dis = new DataInputStream(new BufferedInputStream(
                                        new FileInputStream("C://StreamVSBuffertest.txt")));
 while(dis.readLine() != null){

 }
 dis.close();
 System.out.println(System.currentTimeMillis() - start);
 } catch (FileNotFoundException e) {
 // TODO Auto-generated catch block
 e.printStackTrace();
 }
 }

 public static void main(String[] args){
 try {
StreamVSBuffer.streamMethod();
StreamVSBuffer.bufferMethod();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 }
}

執行結果如清單 4 所示。

清單 4. 執行輸出
889
31

很明顯使用緩衝的程式碼效能比沒有使用緩衝的快了很多倍。清單 5 所示程式碼對 FileWriter 和 FileReader 進行了相似的測試。

清單 5.FileWriter 和 FileReader 程式碼
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

public class WriterVSBuffer {
 public static void streamMethod() throws IOException{
 try {
long start = System.currentTimeMillis();
 FileWriter fw = new FileWriter("C://StreamVSBuffertest.txt");//請替換成自己的檔案
for(int i=0;i<10000;i++){
fw.write(String.valueOf(i)+"/r/n");//迴圈 1 萬次寫入資料
}
fw.close();
FileReader fr = new FileReader("C://StreamVSBuffertest.txt");
while(fr.ready() != false){

}
fr.close();
 System.out.println(System.currentTimeMillis() - start);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

 }

 public static void bufferMethod() throws IOException{
 try {
 long start = System.currentTimeMillis();
 BufferedWriter fw = new BufferedWriter(new FileWriter("C://StreamVSBuffertest.txt"));//請替換成自己的檔案
 for(int i=0;i<10000;i++){
 fw.write(String.valueOf(i)+"/r/n");//迴圈 1 萬次寫入資料
 }
 fw.close();
 BufferedReader fr = new BufferedReader(new FileReader("C://StreamVSBuffertest.txt"));
 while(fr.ready() != false){

 }
 fr.close();
 System.out.println(System.currentTimeMillis() - start);
 } catch (FileNotFoundException e) {
 // TODO Auto-generated catch block
 e.printStackTrace();
 }
 }

 public static void main(String[] args){
 try {
StreamVSBuffer.streamMethod();
StreamVSBuffer.bufferMethod();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
 }
}

執行輸出如清單 6 所示。

清單 6. 執行輸出
1295
31

從上面例子可以看出,無論對於讀取還是寫入檔案,適當地使用緩衝,可以有效地提升系統的檔案讀寫效能,為使用者減少響應時間。

快取

快取也是一塊為提升系統效能而開闢的記憶體空間。快取的主要作用是暫存資料處理結果,並提供下次訪問使用。在很多場合,資料的處理或者資料獲取可能會非常費時,當對這個資料的請求量很大時,頻繁的資料處理會耗盡 CPU 資源。快取的作用就是將這些來之不易的資料處理結果暫存起來,當有其他執行緒或者客戶端需要查詢相同的資料資源時,可以省略對這些資料的處理流程,而直接從快取中獲取處理結果,並立即返回給請求元件,以此提高系統的響應時間。

目前有很多基於 Java 的快取框架,比如 Ehcache、OSCache 和 JBossCache 等。EHCache 快取出自 Hibernate,是其預設的資料快取解決方案;OSCache 快取是有 OpenSymphony 設計的,它可以用於快取任何物件,甚至是快取部分 JSP 頁面或者 HTTP 請求;JBossCache 是由 JBoss 開發、可用於 JBoss 叢集間資料共享的快取框架。

以 EHCache 為例,EhCache 的主要特性有:

  1. 快速;
  2. 簡單;
  3. 多種快取策略;
  4. 快取資料有兩級:記憶體和磁碟,因此無需擔心容量問題;
  5. 快取資料會在虛擬機器重啟的過程中寫入磁碟;
  6. 可以通過 RMI、可插入 API 等方式進行分散式快取;
  7. 具有快取和快取管理器的偵聽介面;
  8. 支援多快取管理器例項,以及一個例項的多個快取區域;
  9. 提供 Hibernate 的快取實現。

由於 EhCache 是程式中的快取系統,一旦將應用部署在叢集環境中,每一個節點維護各自的快取資料,當某個節點對快取資料進行更新,這些更新的資料無法在其它節點中共享,這不僅會降低節點執行的效率,而且會導致資料不同步的情況發生。例如某個網站採用 A、B 兩個節點作為叢集部署,當 A 節點的快取更新後,而 B 節點快取尚未更新就可能出現使用者在瀏覽頁面的時候,一會是更新後的資料,一會是尚未更新的資料,儘管我們也可以通過 Session Sticky 技術來將使用者鎖定在某個節點上,但對於一些互動性比較強或者是非 Web 方式的系統來說,Session Sticky 顯然不太適合。所以就需要用到 EhCache 的叢集解決方案。清單 7 所示是 EHCache 示例程式碼。

清單 7.EHCache 示例程式碼
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Element;
/**
 * 第一步:生成 CacheManager 物件
 * 第二步:生成 Cache 物件
 * 第三步:向 Cache 物件裡新增由 key,value 組成的鍵值對的 Element 元素
 * @author mahaibo
 *
 */
public class EHCacheDemo{

public static void main(String[] args) {
 //指定 ehcache.xml 的位置
 String fileName="E://1008//workspace//ehcachetest//ehcache.xml";
 CacheManager manager = new CacheManager(fileName);
 //取出所有的 cacheName
 String names[] = manager.getCacheNames();
 for(int i=0;i<names.length;i++){
 System.out.println(names[i]);
 }
 //根據 cacheName 生成一個 Cache 物件
 //第一種方式:
 Cache cache=manager.getCache(names[0]);

 //第二種方式,ehcache 裡必須有 defaultCache 存在,"test"可以換成任何值
// Cache cache = new Cache("test", 1, true, false, 5, 2); 
// manager.addCache(cache); 

 //向 Cache 物件裡新增 Element 元素,Element 元素有 key,value 鍵值對組成
 cache.put(new Element("key1","values1"));
 Element element = cache.get("key1");

 System.out.println(element.getValue());
 Object obj = element.getObjectValue();
 System.out.println((String)obj);
 manager.shutdown();

 }

}

物件複用

物件複用池是目前很常用的一種系統優化技術。它的核心思想是,如果一個類被頻繁請求使用,那麼不必每次都生成一個例項,可以將這個類的一些例項儲存在一個“池”中,待需要使用的時候直接從池中獲取。這個“池”就稱為物件池。在實現細節上,它可能是一個陣列,一個連結串列或者任何集合類。物件池的使用非常廣泛,例如執行緒池和資料庫連線池。執行緒池中儲存著可以被重用的執行緒物件,當有任務被提交到執行緒時,系統並不需要新建執行緒,而是從池中獲得一個可用的執行緒,執行這個任務。在任務結束後,不需要關閉執行緒,而將它返回到池中,以便下次繼續使用。由於執行緒的建立和銷燬是較為費時的工作,因此,線上程頻繁排程的系統中,執行緒池可以很好地改善效能。資料庫連線池也是一種特殊的物件池,它用於維護資料庫連線的集合。當系統需要訪問資料庫時,不需要重新建立資料庫連線,而可以直接從池中獲取;在資料庫操作完成後,也不關閉資料庫連線,而是將連線返回到連線池中。由於資料庫連線的建立和銷燬是重量級的操作,因此,避免頻繁進行這兩個操作對改善系統的效能也有積極意義。目前應用較為廣泛的資料庫連線池元件有 C3P0 和 Proxool。

以 C3P0 為例,它是一個開源的 JDBC 連線池,它實現了資料來源和 JNDI 繫結,支援 JDBC3 規範和 JDBC2 的標準擴充套件。目前使用它的開源專案有 Hibernate,Spring 等。如果採用 JNDI 方式配置,如清單 8 所示。

清單 8.Tomcat 資料來源配置
<Resource name="jdbc/dbsource" 
 type="com.mchange.v2.c3p0.ComboPooledDataSource" 
 maxPoolSize="50" minPoolSize="5" acquireIncrement="2" initialPoolSize="10" maxIdleTime="60"
 factory="org.apache.naming.factory.BeanFactory" 
 user="xxxx" password="xxxx" 
 driverClass="oracle.jdbc.driver.OracleDriver" 
 jdbcUrl="jdbc:oracle:thin:@192.168.x.x:1521:orcl" 
 idleConnectionTestPeriod="10" />

引數說明:

  1. idleConnectionTestPerio:當資料庫重啟後或者由於某種原因程式被殺掉後,C3P0 不會自動重新初始化資料庫連線池,當新的請求需要訪問資料庫的時候,此時會報錯誤 (因為連線失效),同時重新整理資料庫連線池,丟棄掉已經失效的連線,當第二個請求到來時恢復正常。C3P0 目前沒有提供當獲取已建立連線失敗後重試次數的引數,只有獲取新連線失敗後重試次數的引數。
  2. acquireRetryAttempts:該引數的作用是設定系統自動檢查連線池中連線是否正常的一個頻率引數,時間單位是秒。
  3. acquireIncremen:當連線池中的的連線耗盡的時候 c3p0 一次同時獲取的連線數,也就是說,如果使用的連線數已經達到了 maxPoolSize,c3p0 會立即建立新的連線。
  4. maxIdleTim:另外,C3P0 預設不會 close 掉不用的連線池,而是將其回收到可用連線池中,這樣會導致連線數越來越大,所以需要設定 maxIdleTime(預設 0,表示永遠不過期),單位是秒,maxIdleTime 表示 idle 狀態的 connection 能存活的最大時間。

如果使用 spring,同時專案中不使用 JNDI,又不想配置 Hibernate,可以直接將 C3P0 配置到 dataSource 中即可,如清單 9 所示。

清單 9.Spring 配置
<bean id="dataSource" destroy-method="close">
<property name="driverClass"><value>oracle.jdbc.driver.OracleDriver</value></property>
<property name="jdbcUrl"><value>jdbc:oracle:thin:@localhost:1521:Test</value></property>
<property name="user"><value>Kay</value></property>
<property name="password"><value>root</value></property>
<!--連線池中保留的最小連線數。-->
<property name="minPoolSize" value="10" />
<!--連線池中保留的最大連線數。Default: 15 -->
<property name="maxPoolSize" value="100" />
<!--最大空閒時間,1800 秒內未使用則連線被丟棄。若為 0 則永不丟棄。Default: 0 -->
<property name="maxIdleTime" value="1800" />
<!--當連線池中的連線耗盡的時候 c3p0 一次同時獲取的連線數。Default: 3 -->
<property name="acquireIncrement" value="3" />
<property name="maxStatements" value="1000" />
<property name="initialPoolSize" value="10" />
<!--每 60 秒檢查所有連線池中的空閒連線。Default: 0 -->
<property name="idleConnectionTestPeriod" value="60" />
<!--定義在從資料庫獲取新連線失敗後重復嘗試的次數。Default: 30 -->
<property name="acquireRetryAttempts" value="30" />
<property name="breakAfterAcquireFailure" value="true" />
<property name="testConnectionOnCheckout" value="false" />
</bean>

類似的做法存在很多種,使用者可以自行上網搜尋。

計算方式轉換

計算方式轉換比較出名的是時間換空間方式,它通常用於嵌入式裝置,或者記憶體、硬碟空間不足的情況。通過使用犧牲 CPU 的方式,獲得原本需要更多記憶體或者硬碟空間才能完成的工作。

一個非常簡單的時間換空間的演算法,實現了 a、b 兩個變數的值交換。交換兩個變數最常用的方法是使用一箇中間變數,而引入額外的變數意味著要使用更多的空間。採用下面的方法可以免去中間變數,而達到變數交換的目的,其代價是引入了更多的 CPU 運算。

清單 10. 示例程式碼
a=a+b;
b=a-b;
a=a-b;

另一個較為有用的例子是對無符號整數的支援。在 Java 語言中,不支援無符號整數,這意味著當需要無符號的 Byte 時,需要使用 Short 代替,這也意味著空間的浪費。下面程式碼演示了使用位運算模擬無符號 Byte。雖然在取值和設值過程中需要更多的 CPU 運算,但是可以大大降低對記憶體空間的需求。

清單 11. 無符號整數運算
public class UnsignedByte {
 public short getValue(byte i){//將 byte 轉為無符號的數字
 short li = (short)(i & 0xff);
 return li;
 }

 public byte toUnsignedByte(short i){
 return (byte)(i & 0xff);//將 short 轉為無符號 byte
 }

 public static void main(String[] args){
 UnsignedByte ins = new UnsignedByte();
 short[] shorts = new short[256];//宣告一個 short 陣列
 for(int i=0;i<shorts.length;i++){//陣列不能超過無符號 byte 的上限
 shorts[i]=(short)i;
 }
 byte[] bytes = new byte[256];//使用 byte 陣列替代 short 陣列
 for(int i=0;i<bytes.length;i++){
 bytes[i]=ins.toUnsignedByte(shorts[i]);//short 陣列的資料存到 byte 陣列中
 }
 for(int i=0;i<bytes.length;i++){
 System.out.println(ins.getValue(bytes[i])+" ");//從 byte 陣列中取出無符號的 byte
 }
 }
}

執行輸出如清單 12 所示,篇幅所限,只顯示到 10 為止。

清單 12. 執行輸出
0 
1 
2 
3 
4 
5 
6 
7 
8 
9 
10

如果 CPU 的能力較弱,可以採用犧牲空間的方式提高計算能力,例項程式碼如清單 13 所示。

清單 13. 提高計算能力
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class SpaceSort {
 public static int arrayLen = 1000000;

 public static void main(String[] args){
 int[] a = new int[arrayLen];
 int[] old = new int[arrayLen];
 Map<Integer,Object> map = new HashMap<Integer,Object>();
 int count = 0;
 while(count < a.length){
 //初始化陣列
 int value = (int)(Math.random()*arrayLen*10)+1;
 if(map.get(value)==null){
 map.put(value, value);
 a[count] = value;
 count++;
 }
 }
 System.arraycopy(a, 0, old, 0, a.length);//從 a 陣列拷貝所有資料到 old 陣列
 long start = System.currentTimeMillis();
 Arrays.sort(a);
 System.out.println("Arrays.sort spend:"+(System.currentTimeMillis() - start)+"ms");
 System.arraycopy(old, 0, a, 0, old.length);//恢復 原有資料
 start = System.currentTimeMillis();
 spaceTotime(a);
 System.out.println("spaceTotime spend:"+(System.currentTimeMillis() - start)+"ms");
 }

 public static void spaceTotime(int[] array){
 int i = 0;
 int max = array[0];
 int l = array.length;
 for(i=1;i<l;i++){
 if(array[i]>max){
 max = array[i];
 }
 }
 int[] temp = new int[max+1];
 for(i=0;i<l;i++){
 temp[array[i]] = array[i];
 }
 int j = 0;
 int max1 = max + 1;
 for(i=0;i<max1;i++){
 if(temp[i] > 0){
 array[j++] = temp[i];
 }
 }
 }
}

函式 spaceToTime() 實現了陣列的排序,它不計空間成本,以陣列的索引下標來表示資料大小,因此避免了數字間的相互比較,這是一種典型的以空間換時間的思路。

結束語

應對、處理高吞吐量系統有很多方面可以入手,作者將以系列的方式逐步介紹覆蓋所有領域。本文主要介紹了緩衝區、快取操作、物件複用池、計算方式轉換等優化及建議,從實際程式碼演示入手,對優化建議及方案進行了驗證。作者始終堅信,沒有什麼優化方案是百分百有效的,需要讀者根據實際情況進行選擇、實踐。

相關文章