1. 程式人生 > 其它 >面向服務的體系架構

面向服務的體系架構

原書《大型分散式網站架構設計與實踐》第一章——面向服務的體系架構(SOA)

RPC

如上圖,最初的應用是單體架構,一臺伺服器就可以完成所有工作,如果伺服器的效能無法滿足需求就升級伺服器配置。當應用規模越來越大,單體應用架構中的邏輯越來越複雜,再加上對單臺伺服器的配置的擴充成本過高,人們將單體應用垂直劃分成多個子系統當中。

當規模再度增加,應用中越來越多的垂直模組變得難以管理,模組間相互互動的需求越來越多,模組中核心的功能被抽取出來作為單獨的系統向外提供服務,不同業務之間可以重複呼叫,這就形成了目前的分散式應用架構。

一個模組向外提供服務和使用其它服務都離不開遠端過程呼叫(RPC),RPC就是指通過某種手段呼叫其它系統中的服務,比如通過TCP,或者HTTP。

物件的序列化

進行遠端過程呼叫一定要傳遞一些物件,這些物件必須要在服務的使用端(consumer)被序列化,在服務的提供端(provider)被反序列化。

Java中你可以使用很多種序列化機制,大體上分為二進位制編碼和某種純文字格式的。二進位制編碼如Java自帶的序列化機制、Hessian,純文字可以使用XML和JSON。

遠端呼叫示例

下面是一個sayhello服務介面,和一個簡單的實現,這個實現當傳入引數是hello時返回hi,否則返回byebye

interface SayhelloService {
    String sayHello(String helloArg);
}

class SayhelloServiceImpl implements SayhelloService {

    public String sayHello(String helloArg) {
        if (helloArg.equals("hello")) {
            return "hi";
        } else {
            return "byebye";
        }
    }

}

遠端呼叫的目的就是提供一些手段,在其它系統上呼叫這個服務。下面構造RPC的客戶端,它提供一個rCall函式發起RPC呼叫,呼叫者需要提供服務名,呼叫的方法名,引數型別和引數,rCall中使用Hessian進行二進位制編碼,使用套接字介面向服務端傳送TCP資料。

public class RPCClient {
    public void rCall(String serviceName, String serviceMethod, Class<?>[] paramTypes, Object[] args) throws IOException {
        System.out.println("Sending rCall " + serviceName + "." + serviceMethod + "...");

        Socket socket = new Socket("127.0.0.1", Constants.INSTANCE.getSERVER_PORT());
        HessianOutput ho = new HessianOutput(socket.getOutputStream());
        ho.writeString(serviceName);
        ho.writeString(serviceMethod);
        ho.writeObject(paramTypes);
        ho.writeObject(args);

        Hessian2Input hi = new Hessian2Input(socket.getInputStream());
        System.out.println(hi.readObject());
    }
}

下面是RPC呼叫的伺服器端,伺服器端使用一個Map來預先註冊服務列表,並且對於每個呼叫請求,找到對應的服務方法和引數,進行呼叫,並寫回返回值。

public class RPCServer {

    private final Map<String, Object> rpcServicies;

    public RPCServer() {
        rpcServicies = new HashMap<>();
        rpcServicies.put("sayhello", new SayhelloServiceImpl());
    }

    public void serveForever() throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        System.out.println("Start serving...");

        ServerSocket server = new ServerSocket(Constants.INSTANCE.getSERVER_PORT());

        while (true) {
            Socket socket = server.accept();
            System.out.println("Got rCall from " + socket.getInetAddress().getHostAddress());
            HessianInput hi = new HessianInput(socket.getInputStream());
            String interfaceName = hi.readString();
            String methodName = hi.readString();
            Class<?>[] paramTypes = (Class<?>[]) hi.readObject();
            Object[] arguments = (Object[]) hi.readObject();

            if (rpcServicies.containsKey(interfaceName)) {
                Object service = rpcServicies.get(interfaceName);
                Object result = service.getClass().getMethod(methodName, paramTypes).invoke(service, arguments);
                HessianOutput ho = new HessianOutput(socket.getOutputStream());
                ho.writeObject(result);
            }

        }
    }
}

下面是兩個客戶端和伺服器的呼叫示例

// server
RPCServer server = new RPCServer();
server.serveForever();

// client
RPCClient client = new RPCClient();
client.rCall("sayhello", "sayHello", new Class[] {String.class}, new Object[] {"hello"});

使用基於HTTP的RPC,我們不必糾結底層實現細節,很多已經成熟的HTTP伺服器甚至開發框架都可以被直接利用,這意味著我們不必考慮我們伺服器基礎設施的正確性以及併發管理。我們上面基於TCP實現的RCP伺服器沒有併發,它一次只能服務一個呼叫者。

HTTP的劣勢就是在同等資料量的交換下,HTTP的效能肯定不如TCP,因為畢竟HTTP是上層協議,它會帶有很多用於描述自身的位元組。使用gzip壓縮可以減小這一劣勢。

服務路由和負載均衡

從上面的一些描述中,我們可以看出現在的分散式系統是將各種微型公共業務拆分出來,作為服務向外部提供功能,並且服務間存在相互呼叫,一個使用者發起的請求可能需要多個服務共同完成。這樣設計的好處是可以將注意力聚焦在單個模組上,減少錯誤,方便維護和複用。這種設計就稱為SOA(Service-Oriented Architecture)。

上面的例子說明,在SOA系統中有一系列的服務,為了使這些服務的可用性更高,通常一個服務會有多個例項,任何一個例項作為一個單獨的系統,都能提供這個服務,當consumer請求服務時,通過負載均衡演算法選擇一個具體的服務例項

這樣做的好處一是提高效能,相當於有多個東西同時為使用者服務,再有就是當一個服務例項出現故障時,其它例項依然可以正常提供服務。

靜態編碼服務列表

我們可以在伺服器啟動之初靜態的配置或者硬編碼一個服務的所有服務例項,並使用硬體負載均衡裝置或軟體(如Nginx)來實現負載均衡。如下圖,這就是一個最簡單的靜態編碼服務列表的SOA系統。

考慮一個問題,此時若業務需求要求我們擴充套件一個服務或者只是在一個服務中新增一個服務例項,或者某個服務的一個服務例項宕機了該怎麼辦。在靜態編碼的情況下只能修改原始碼,新增或去掉一些內容,重啟伺服器。而且,如果某個服務的負載均衡裝置發生了故障,所有服務都將失效,即使它們能正常工作。

靜態編碼的痛點就是我們的業務稍有改動就必須修改程式碼,重啟伺服器,此時我們需要一種動態註冊以及管理服務資訊的方法。

服務配置中心

服務配置中心相當於一個註冊處(Regristry),服消提供者向它報告自己的服務資訊,服務使用者通過它來獲取能夠使用的服務。當某個服務的一臺伺服器宕機或下線,相應的機器需要能夠動態地從服務配置中心中移除。

還有個詞叫去中心化,它是指服務消費者以訂閱的形式瞭解服務配置中心中的服務資訊變更。消費者先快取服務配置中心中的內容到本地,然後它不再訪問服務配置中心,而是讀取本地拉取下來的服務配置。當服務配置中心中的服務資訊發生變更,消費者就會接收到通知,並重新去中心獲取相應的服務列表。

ZooKeeper可以完成服務配置中心的工作,它可以近乎實時的感受到提供服務的伺服器的狀態,通過zab協議保證叢集間的服務配置資訊一致。後面介紹。

負載均衡演算法

輪詢(Round Robin)

每個服務的服務例項列表可以被看作一個環形佇列,指標從佇列頭開始,每個服務呼叫到來,使用當前指標位置的服務例項,並且將指標下移一個位置。

輪詢法需要使用鎖機制來控制指標在併發情況下的正確下移,這是一筆開銷。

隨機(Random)

每次隨機使用一個服務例項。

該演算法簡單,開銷小,並且在訪問量大的情況下無限接近於輪詢法。

源地址雜湊

使用雜湊演算法得到一個服務例項。

加權輪詢

和輪詢一致,只不過對於服務能力強的服務例項的權重更高。

下面的虛擬碼是加權輪詢構造輪詢佇列時的一種寫法,在該演算法下,權重為1的伺服器只會在佇列中存在一次,權重為4的就會存在4次,所以權重高的比權重低的得到的呼叫次數更多。

round_queue = []
for server in all_server:
  for i in 0 to server.weight:
    round_queue.enqueue(server)

加權隨機

和隨機一致,只不過對於服務能力強的伺服器權重更高。

最小連線數

上面的所有演算法都只是考慮到將請求平均分配到每個伺服器上,沒有考慮有些請求相對於另外一些更復雜,需要的響應時間更長。

最小連線數選取當前服務例項中連線數最小的伺服器。

動態配置規則

負載均衡策略沒有哪個好哪個壞之說,不同的場景適合不同的策略,可以通過Groovy指令碼或其它手段來動態配置使用的負載均衡策略。

ZooKeeper實現服務配置中心的示例

安裝我就不記了,並且這裡原書也沒過多的介紹ZooKeeper,只是讓大家體驗一下使用ZooKeeper實現中心化的服務配置中心。

先要介紹一下,ZooKeeper中的關鍵是znode,也就是一個節點,這有點類似於Linux下的檔案系統。每個節點中可以儲存一些自己的資料,節點間可以有父子關係。使用ZooKeeper可以搭建成這樣的服務配置中心,其中每一個圈圈都是一個znode

下面來看看ZooKeeper的基本用法

  • 建立一個公用的持久節點
ZooKeeper zooKeeper = new ZooKeeper("192.168.50.2", SESSION_TIMEOUT, null);
zooKeeper.create("/root", "root data".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
  • 使用stat獲取節點狀態
Stat stat = zookeeper.exists(path, false);
if (stat == null) {
  // 節點不存在
} else {
  // 節點已存在
}
  • 使用Watcher監聽節點的狀態改變
public class RootZKWatcher implements Watcher {

    private final ZooKeeper zookeeper;

    public RootZKWatcher(ZooKeeper zooKeeper) {
        this.zookeeper = zooKeeper;
    }

    public void regWatcher() throws InterruptedException, KeeperException {
        zookeeper.addWatch("/root", this, AddWatchMode.PERSISTENT);
    }

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println("[+] watcher process : " + watchedEvent.getPath());

        switch (watchedEvent.getType()) {
            case NodeCreated:
                System.out.println("[+] Node created!");
                break;
            case NodeDeleted:
                System.out.println("[+] Node deleted!");
                break;
            case NodeDataChanged:
                System.out.println("[+] Node data changed!");
                break;
            case NodeChildrenChanged:
                System.out.println("[+] Node children changed!");
                break;
        }

    }
}

需要注意的是,ZooKeeper的Watcher註冊是一次性的,消費過後需要重新註冊。可以使用zkClient庫將這個註冊機制轉換為訂閱釋出機制。

當服務間呼叫過於複雜時,可以使用下圖的結構,即每個服務又通過consumer子節點記錄誰呼叫了它,通過provider子節點記錄它需要哪些服務。

HTTP服務閘道器

閘道器過濾惡意或有問題的服務請求。如果需要提高可用性,閘道器也需要以叢集的方式提供服務,並且通過兩臺互相進行心跳檢測的負載均衡伺服器來提供負載均衡。