天天看點

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

高吞吐量系統

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

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

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

一、緩沖 (Buffer)

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

1、使用 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();
	}

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

2、使用 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) + "rn"); //循環 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) + "rn"); //循環 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);rt);
			
        } 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);
        String names[] = manager.getCacheNames();//取出所有的 cacheName
        
		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" class="com.mchange.v2.c3p0.ComboPooledDataSource" 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 {
    //将 byte 轉為無符号的數字
    public short getValue(byte i) {
        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 數組
        
        //數組不能超過無符号 byte 的上限
        for(int i = 0; i < shorts.length; i++) {
            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 & lt; 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() 實作了數組的排序,它不計空間成本,以數組的索引下标來表示資料大小,是以避免了數字間的互相比較,這是一種典型的以空間換時間的思路。

結束語

  • 參考 developerWorks 中國關于高吞吐量系統 檢索頁面,檢視 IBM 開發者論壇公布的關于高吞吐量系統的相關文章。
  • 檢視文章“JAVA 的緩存的實作”,作者對于緩存的實作作出了基本解釋。
  • 檢視書籍《Java 性能優化權威指南》,作者對于 Java 性能優化給出了權威的指導意見。
  • developerWorks Java 技術專區:這裡有數百篇關于 Java 程式設計各個方面的文章。

繼續閱讀