架構設計&分佈式&數據結構與算法面試題(2020最新版)

架構設計

請列舉出在JDK中幾個常用的設計模式?

單例模式(Singleton pattern)用於Runtime,Calendar和其他的一些類中。工廠模式(Factory pattern)被用於各種不可變的類如 Boolean,像Boolean.valueOf,觀察者模式(Observer pattern)被用於 Swing 和很多的事件監聽中。裝飾器設計模式(Decorator design pattern)被用於多個 Java IO 類中。

什麼是設計模式?你是否在你的代碼裏面使用過任何設計模式?

設計模式是軟件開發人員在軟件開發過程中面臨的一般問題的解決方案。這些解決方案是衆多軟件開發人員經過相當長的一段時間的試驗和錯誤總結出來的。設計模式是代碼可用性的延伸

設計模式分類:創建型模式,結構型模式,行爲型模式

靜態代理、JDK動態代理以及CGLIB動態代理

代理模式是java中最常用的設計模式之一,尤其是在spring框架中廣泛應用。對於java的代理模式,一般可分爲:靜態代理、動態代理、以及CGLIB實現動態代理。

對於上述三種代理模式,分別進行說明。

靜態代理

靜態代理其實就是在程序運行之前,提前寫好被代理方法的代理類,編譯後運行。在程序運行之前,class已經存在。
下面我們實現一個靜態代理demo:

img

定義一個接口Target

package com.test.proxy;

public interface Target {

    public String execute();
}

TargetImpl 實現接口Target

package com.test.proxy;

public class TargetImpl implements Target {

    @Override
    public String execute() {
        System.out.println("TargetImpl execute!");
        return "execute";
    }
}

代理類

package com.test.proxy;

public class Proxy implements Target{

    private Target target;

    public Proxy(Target target) {
        this.target = target;
    }

    @Override
    public String execute() {
        System.out.println("perProcess");
        String result = this.target.execute();
        System.out.println("postProcess");
        return result;
    }
}

測試類:

package com.test.proxy;

public class ProxyTest {

    public static void main(String[] args) {

        Target target = new TargetImpl();
        Proxy p = new Proxy(target);
        String result =  p.execute();
        System.out.println(result);
    }

}

運行結果:

perProcess
TargetImpl execute!
postProcess
execute

靜態代理需要針對被代理的方法提前寫好代理類,如果被代理的方法非常多則需要編寫很多代碼,因此,對於上述缺點,通過動態代理的方式進行了彌補。

動態代理

動態代理主要是通過反射機制,在運行時動態生成所需代理的class.

img

接口

package com.test.dynamic;

public interface Target {

    public String execute();
}

實現類

package com.test.dynamic;

public class TargetImpl implements Target {

    @Override
    public String execute() {
        System.out.println("TargetImpl execute!");
        return "execute";
    }
}

代理類

package com.test.dynamic;


import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class DynamicProxyHandler implements InvocationHandler{

    private Target target;

    public DynamicProxyHandler(Target target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("========before==========");
        Object result = method.invoke(target,args);
        System.out.println("========after===========");
        return result;
    }
}

測試類

package com.test.dynamic;

import java.lang.reflect.Proxy;

public class DynamicProxyTest {

    public static void main(String[] args) {
        Target target = new TargetImpl();
        DynamicProxyHandler handler = new DynamicProxyHandler(target);
        Target proxySubject = (Target) Proxy.newProxyInstance(TargetImpl.class.getClassLoader(),TargetImpl.class.getInterfaces(),handler);
        String result = proxySubject.execute();
        System.out.println(result);
    }

}

運行結果:

========before==========
TargetImpl execute!
========after===========
execute

無論是動態代理還是靜態帶領,都需要定義接口,然後才能實現代理功能。這同樣存在侷限性,因此,爲了解決這個問題,出現了第三種代理方式:cglib代理。

cglib代理

CGLib採用了非常底層的字節碼技術,其原理是通過字節碼技術爲一個類創建子類,並在子類中採用方法攔截的技術攔截所有父類方法的調用,順勢織入橫切邏輯。JDK動態代理與CGLib動態代理均是實現Spring AOP的基礎。

img

目標類

package com.test.cglib;

public class Target {

    public String execute() {
        String message = "-----------test------------";
        System.out.println(message);
        return message;
    }
}

通用代理類

package com.test.cglib;

import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

public class MyMethodInterceptor implements MethodInterceptor{

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println(">>>>MethodInterceptor start...");
        Object result = proxy.invokeSuper(obj,args);
        System.out.println(">>>>MethodInterceptor ending...");
        return "result";
    }
}

測試類

package com.test.cglib;

import net.sf.cglib.proxy.Enhancer;

public class CglibTest {

    public static void main(String[] args) {
        System.out.println("***************");
        Target target = new Target();
        CglibTest test = new CglibTest();
        Target proxyTarget = (Target) test.createProxy(Target.class);
        String res = proxyTarget.execute();
        System.out.println(res);
    }

    public Object createProxy(Class targetClass) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(targetClass);
        enhancer.setCallback(new MyMethodInterceptor());
        return enhancer.create();
    }

}

執行結果:

***************
>>>>MethodInterceptor start...
-----------test------------
>>>>MethodInterceptor ending...
result

代理對象的生成過程由Enhancer類實現,大概步驟如下:

  1. 生成代理類Class的二進制字節碼;

  2. 通過Class.forName加載二進制字節碼,生成Class對象;

  3. 通過反射機制獲取實例構造,並初始化代理類對象。

單例模式

單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。

意圖:保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。

主要解決:一個全局使用的類頻繁地創建與銷燬。

懶漢式,線程安全

代碼實例

public class Singleton2 {

    private static Singleton2 instance;

    private Singleton2() {}

    public static synchronized Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }

        return instance;
    }

}

餓漢式,線程安全

代碼實例

public class Singleton3 {

    private static Singleton3 instance = new Singleton3();

    private Singleton3() {}

    public static Singleton3 getInstance() {
        return instance;
    }

} 

雙檢鎖/雙重校驗鎖 + volatile關鍵字

代碼實例

public class Singleton7 {

    private static volatile Singleton7 instance = null;

    private Singleton7() {}

    public static Singleton7 getInstance() {
        if (instance == null) {
            synchronized (Singleton7.class) {
                if (instance == null) {
                    instance = new Singleton7();
                }
            }
        }

        return instance;
    }

}

工廠模式

工廠模式(Factory Pattern)是 Java 中最常用的設計模式之一。這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。

意圖:定義一個創建對象的接口,讓其子類自己決定實例化哪一個工廠類,工廠模式使其創建過程延遲到子類進行。

主要解決:主要解決接口選擇的問題。

我們將創建一個 Shape 接口和實現 Shape 接口的實體類。下一步是定義工廠類 ShapeFactory

FactoryPatternDemo,我們的演示類使用 ShapeFactory 來獲取 Shape 對象。它將向 ShapeFactory 傳遞信息(CIRCLE / RECTANGLE / SQUARE),以便獲取它所需對象的類型。

工廠模式

步驟 1

創建一個接口。

Shape.java

public interface Shape {
    void draw();
}

步驟 2

創建實現接口的實體類。

Rectangle.java

public class Rectangle implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Rectangle::draw() method.");
    }
}

Square.java

public class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Square::draw() method.");
    }
}

Circle.java

public class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Inside Circle::draw() method.");
    }
}

步驟 3

創建一個工廠,生成基於給定信息的實體類的對象。

ShapeFactory.java

public class ShapeFactory {

    //使用 getShape 方法獲取形狀類型的對象
    public Shape getShape(String shapeType) {
        if (shapeType == null) {
            return null;
        }
        shapeType = shapeType.toLowerCase();

        switch (shapeType) {
            case "circle":
                return new Circle();
            case "rectangle":
                return new Rectangle();
            case "square":
                return new Square();
            default:
                return null;
        }

    }

}

步驟 4

使用該工廠,通過傳遞類型信息來獲取實體類的對象。

FactoryPatternDemo.java

public class FactoryPatternDemo {

    public static void main(String[] args) {
        ShapeFactory shapeFactory = new ShapeFactory();

        //獲取 Circle 的對象,並調用它的 draw 方法
        Shape shape1 = shapeFactory.getShape("CIRCLE");
        //調用 Circle 的 draw 方法
        shape1.draw();

        //獲取 Rectangle 的對象,並調用它的 draw 方法
        Shape shape2 = shapeFactory.getShape("RECTANGLE");
        //調用 Rectangle 的 draw 方法
        shape2.draw();

        //獲取 Square 的對象,並調用它的 draw 方法
        Shape shape3 = shapeFactory.getShape("SQUARE");
        //調用 Square 的 draw 方法
        shape3.draw();
    }

}

步驟 5

驗證輸出。

Inside Circle::draw() method.
Inside Rectangle::draw() method.
Inside Square::draw() method.

觀察者模式

當對象間存在一對多關係時,則使用觀察者模式(Observer Pattern)。比如,當一個對象被修改時,則會自動通知它的依賴對象。觀察者模式屬於行爲型模式。

意圖:定義對象間的一種一對多的依賴關係,當一個對象的狀態發生改變時,所有依賴於它的對象都得到通知並被自動更新。

主要解決:一個對象狀態改變給其他對象通知的問題,而且要考慮到易用和低耦合,保證高度的協作。

實現

觀察者模式使用三個類 Subject、Observer 和 Client。Subject 對象帶有綁定觀察者到 Client 對象和從 Client 對象解綁觀察者的方法。我們創建 Subject 類、Observer 抽象類和擴展了抽象類 Observer 的實體類。

ObserverPatternDemo,我們的演示類使用 Subject 和實體類對象來演示觀察者模式。

觀察者模式

步驟 1

創建 Subject 類。

Subject.java

public class Subject {

    private List<Observer> observers = new ArrayList<>();

    private int state;

    public int getState() {
        return state;
    }

    public void setState(int state) {
        this.state = state;
        notifyAllObservers();
    }

    public void attach(Observer observer) {
        observers.add(observer);
    }

    public void notifyAllObservers() {
        for (Observer observer : observers) {
            observer.update();
        }
    }

}

步驟 2

創建 Observer 類。

Observer.java

public abstract class Observer {

    protected Subject subject;

    public abstract void update();

}

步驟 3

創建實體觀察者類。

BinaryObserver.java

public class BinaryObserver extends Observer {

    public BinaryObserver(Subject subject) {
        this.subject = subject;
        this.subject.attach(this);
    }

    @Override
    public void update() {
        System.out.println("Binary String: "
                + Integer.toBinaryString(subject.getState()));
    }

}

OctalObserver.java

public class OctalObserver extends Observer {

    public OctalObserver(Subject subject){
        this.subject = subject;
        this.subject.attach(this);
    }

    @Override
    public void update() {
        System.out.println( "Octal String: "
                + Integer.toOctalString( subject.getState() ) );
    }

}

HexaObserver.java

public class HexaObserver extends Observer {

    public HexaObserver(Subject subject){
        this.subject = subject;
        this.subject.attach(this);
    }

    @Override
    public void update() {
        System.out.println( "Hex String: "
                + Integer.toHexString( subject.getState() ).toUpperCase() );
    }

}

步驟 4

使用 Subject 和實體觀察者對象。

ObserverPatternDemo.java

public class ObserverPatternDemo {

    public static void main(String[] args) {
        Subject subject = new Subject();

        new BinaryObserver(subject);
        new HexaObserver(subject);
        new OctalObserver(subject);

        System.out.println("First state change: 15");
        subject.setState(15);
        System.out.println();

        System.out.println("Second state change: 10");
        subject.setState(10);
    }

}

步驟 5

驗證輸出。

First state change: 15
Binary String: 1111
Hex String: F
Octal String: 17

Second state change: 10
Binary String: 1010
Hex String: A
Octal String: 12

裝飾器模式

裝飾器模式(Decorator Pattern)允許向一個現有的對象添加新的功能,同時又不改變其結構。這種類型的設計模式屬於結構型模式,它是作爲現有的類的一個包裝。

意圖:動態地給一個對象添加一些額外的職責。就增加功能來說,裝飾器模式相比生成子類更爲靈活。

主要解決:一般的,我們爲了擴展一個類經常使用繼承方式實現,由於繼承爲類引入靜態特徵,並且隨着擴展功能的增多,子類會很膨脹。

實現

我們將創建一個 Shape 接口和實現了 Shape 接口的實體類。然後我們創建一個實現了 Shape 接口的抽象裝飾類 ShapeDecorator,並把 Shape 對象作爲它的實例變量。

RedShapeDecorator 是實現了 ShapeDecorator 的實體類。

DecoratorPatternDemo,我們的演示類使用 RedShapeDecorator 來裝飾 Shape 對象。

裝飾器模式

步驟 1

創建一個接口。

Shape.java

public interface Shape {

    void draw();

}

步驟 2

創建實現接口的實體類。

Rectangle.java

public class Rectangle implements Shape {

    @Override
    public void draw() {
        System.out.println("Shape: Rectangle");
    }

}

Circle.java

public class Circle implements Shape {

    @Override
    public void draw() {
        System.out.println("Shape: Circle");
    }

}

步驟 3

創建實現了 Shape 接口的抽象裝飾類。

ShapeDecorator.java

public abstract class ShapeDecorator implements Shape {

    protected Shape decoratorShape;

    public ShapeDecorator(Shape decoratorShape) {
        this.decoratorShape = decoratorShape;
    }

    @Override
    public void draw() {
        decoratorShape.draw();
    }
}

步驟 4

創建擴展了 ShapeDecorator 類的實體裝飾類。

RedShapeDecorator.java

public class RedShapeDecorator extends ShapeDecorator {

    public RedShapeDecorator(Shape decoratorShape) {
        super(decoratorShape);
    }

    @Override
    public void draw() {
        decoratorShape.draw();
        setRedBorder(decoratorShape);
    }

    private void setRedBorder(Shape decoratorShape) {
        System.out.println("Border Color: Red");
    }

}

步驟 5

使用 RedShapeDecorator 來裝飾 Shape 對象。

DecoratorPatternDemo.java

public class DecoratorPatternDemo {

    public static void main(String[] args) {
        Shape circle = new Circle();
        Shape redCircle = new RedShapeDecorator(new Circle());
        Shape redRectangle = new RedShapeDecorator(new Rectangle());

        System.out.println("Circle with normal border");
        circle.draw();

        System.out.println("\nCircle of red border");
        redCircle.draw();

        System.out.println("\nRectangle of red border");
        redRectangle.draw();

    }

}

步驟 6

驗證輸出。

Circle with normal border
Shape: Circle

Circle of red border
Shape: Circle
Border Color: Red

Rectangle of red border
Shape: Rectangle
Border Color: Red

秒殺系統設計

什麼是秒殺

通俗一點講就是網絡商家爲促銷等目的組織的網上限時搶購活動

業務特點

  • 高併發:秒殺的特點就是這樣時間極短瞬間用戶量大

  • 庫存量少:一般秒殺活動商品量很少,這就導致了只有極少量用戶能成功購買到。

  • 業務簡單:流程比較簡單,一般都是下訂單、扣庫存、支付訂單

  • 惡意請求,數據庫壓力大

解決方案

前端:頁面資源靜態化,按鈕控制,使用答題校驗碼可以防止秒殺器的干擾,讓更多用戶有機會搶到

nginx:校驗惡意請求,轉發請求,負載均衡;動靜分離,不走tomcat獲取靜態資源;gzip壓縮,減少靜態文件傳輸的體積,節省帶寬,提高渲染速度

業務層:集羣,多臺機器處理,提高併發能力

redis:集羣保證高可用,持久化數據;分佈式鎖(悲觀鎖);緩存熱點數據(庫存)

mq:削峯限流,MQ堆積訂單,保護訂單處理層的負載,Consumer根據自己的消費能力來取Task,實際上下游的壓力就可控了。重點做好路由層和MQ的安全

數據庫:讀寫分離,拆分事務提高併發度

秒殺系統設計小結

  • 秒殺系統就是一個“三高”系統,即高併發、高性能高可用的分佈式系統
  • 秒殺設計原則:前臺請求儘量少,後臺數據儘量少,調用鏈路儘量短,儘量不要有單點
  • 秒殺高併發方法:訪問攔截、分流、動靜分離
  • 秒殺數據方法:減庫存策略、熱點、異步、限流降級
  • 訪問攔截主要思路:通過CDN和緩存技術,儘量把訪問攔截在離用戶更近的層,儘可能地過濾掉無效請求。
  • 分流主要思路:通過分佈式集羣技術,多臺機器處理,提高併發能力。

分佈式

分佈式概述

分佈式

分佈式(distributed)是爲了解決單個物理服務器容量和性能瓶頸問題而採用的優化手段,將一個業務拆分成不同的子業務,分佈在不同的機器上執行。服務之間通過遠程調用協同工作,對外提供服務。

該領域需要解決的問題極多,在不同的技術層面上,又包括:分佈式緩存、分佈式數據庫、分佈式計算、分佈式文件系統等,一些技術如MQ、Redis、zookeeper等都跟分佈式有關。

從理念上講,分佈式的實現有兩種形式:

水平擴展:當一臺機器扛不住流量時,就通過添加機器的方式,將流量平分到所有服務器上,所有機器都可以提供 相同的服務;

垂直拆分:前端有多種查詢需求時,一臺機器扛不住,可以將不同的業務需求分發到不同的機器上,比如A機器處理餘票查詢的請求,B機器處理支付的請求。

集羣

集羣(cluster)是指在多臺不同的服務器中部署相同應用或服務模塊,構成一個集羣,通過負載均衡設備對外提供服務。

兩個特點

可擴展性:集羣中的服務節點,可以動態的添加機器,從而增加集羣的處理能力。

高可用性:如果集羣某個節點發生故障,這臺節點上面運行的服務,可以被其他服務節點接管,從而增強集羣的高可用性。

兩大能力

負載均衡:負載均衡能把任務比較均衡地分佈到集羣環境下的計算和網絡資源。

集羣容錯:當我們的系統中用到集羣環境,因爲各種原因在集羣調用失敗時,集羣容錯起到關鍵性的作用。

微服務

微服務就是很小的服務,小到一個服務只對應一個單一的功能,只做一件事。這個服務可以單獨部署運行,服務之間通過遠程調用協同工作,每個微服務都是由獨立的小團隊開發,測試,部署,上線,負責它的整個生命週期。

多線程

多線程(multi-thread):多線程是指程序中包含多個執行流,即在一個程序中可以同時運行多個不同的線程來執行不同的任務。多線程是爲了提高CPU的利用率。

高併發

高併發(High Concurrency)是一種系統運行過程中發生了一種“短時間內遇到大量請求”的情況,高併發對應的是訪問請求,多線程是解決高併發的方法之一,高併發還可以通過分佈式,集羣,算法優化,數據庫優化等方法解決。

分佈式系統設計理念

分佈式系統的目標與要素

分佈式系統的目標是提升系統的整體性能和吞吐量另外還要儘量保證分佈式系統的容錯性(假如增加10臺服務器才達到單機運行效果2倍左右的性能,那麼這個分佈式系統就根本沒有存在的意義)。

即使採用了分佈式系統,我們也要盡力運用併發編程、高性能網絡框架等等手段提升單機上的程序性能。

分佈式系統設計兩大思路:中心化和去中心化

分佈式系統設計兩大思路:中心化和去中心化

中心化設計

  • 兩個角色: 中心化的設計思想很簡單,分佈式集羣中的節點機器按照角色分工,大體上分爲兩種角色: “領導”“幹活的”
  • 角色職責: “領導”通常負責分發任務並監督“幹活的”,發現誰太閒了,就想發設法地給其安排新任務,確保沒有一個“幹活的”能夠偷懶,如果“領導”發現某個“幹活的”因爲勞累過度而病倒了,則是不會考慮先嚐試“醫治”他的,而是一腳踢出去,然後把他的任務分給其他人。其中微服務架構 Kubernetes 就恰好採用了這一設計思路。
  • 中心化設計的問題
    1. 中心化的設計存在的最大問題是“領導”的安危問題,如果“領導”出了問題,則羣龍無首,整個集羣就奔潰了。但我們難以同時安排兩個“領導”以避免單點問題。
    2. 中心化設計還存在另外一個潛在的問題,既“領導”的能力問題:可以領導10個人高效工作並不意味着可以領導100個人高效工作,所以如果系統設計和實現得不好,問題就會卡在“領導”身上。
  • 領導安危問題的解決辦法: 大多數中心化系統都採用了主備兩個“領導”的設計方案,可以是熱備或者冷備,也可以是自動切換或者手動切換,而且越來越多的新系統都開始具備自動選舉切換“領導”的能力,以提升系統的可用性。

去中心化設計

  • 衆生地位平等: 在去中心化的設計裏,通常沒有“領導”和“幹活的”這兩種角色的區分,大家的角色都是一樣的,地位是平等的,全球互聯網就是一個典型的去中心化的分佈式系統,聯網的任意節點設備宕機,都只會影響很小範圍的功能。
  • “去中心化”不是不要中心,而是由節點來自由選擇中心。 (集羣的成員會自發的舉行“會議”選舉新的“領導”主持工作。最典型的案例就是ZooKeeper及Go語言實現的Etcd)
  • 去中心化設計的問題: 去中心化設計裏最難解決的一個問題是 “腦裂”問題 ,這種情況的發生概率很低,但影響很大。腦裂指一個集羣由於網絡的故障,被分爲至少兩個彼此無法通信的單獨集羣,此時如果兩個集羣都各自工作,則可能會產生嚴重的數據衝突和錯誤。一般的設計思路是,當集羣判斷髮生了腦裂問題時,規模較小的集羣就“自殺”或者拒絕服務。

分佈式與集羣的區別是什麼?

  • 分佈式: 一個業務分拆多個子業務,部署在不同的服務器上
  • 集羣: 同一個業務,部署在多個服務器上。比如之前做電商網站搭的redis集羣以及solr集羣都是屬於將redis服務器提供的緩存服務以及solr服務器提供的搜索服務部署在多個服務器上以提高系統性能、併發量解決海量存儲問題。

CAP定理

CAP定理

在理論計算機科學中,CAP定理(CAP theorem),又被稱作布魯爾定理(Brewer’s theorem),它指出對於一個分佈式計算系統來說,不可能同時滿足以下三點:

選項 描述
Consistency(一致性) 指數據在多個副本之間能夠保持一致的特性(嚴格的一致性)
Availability(可用性) 指系統提供的服務必須一直處於可用的狀態,每次請求都能獲取到非錯的響應(不保證獲取的數據爲最新數據)
Partition tolerance(分區容錯性) 分佈式系統在遇到任何網絡分區故障的時候,仍然能夠對外提供滿足一致性和可用性的服務,除非整個網絡環境都發生了故障

Spring Cloud在CAP法則上主要滿足的是A和P法則,Dubbo和Zookeeper在CAP法則主要滿足的是C和P法則

CAP僅適用於原子讀寫的NOSQL場景中,並不適合數據庫系統。現在的分佈式系統具有更多特性比如擴展性、可用性等等,在進行系統設計和開發時,我們不應該僅僅侷限在CAP問題上。

注意:不是所謂的3選2(不要被網上大多數文章誤導了)

現實生活中,大部分人解釋這一定律時,常常簡單的表述爲:“一致性、可用性、分區容忍性三者你只能同時達到其中兩個,不可能同時達到”。實際上這是一個非常具有誤導性質的說法,而且在CAP理論誕生12年之後,CAP之父也在2012年重寫了之前的論文。

當發生網絡分區的時候,如果我們要繼續服務,那麼強一致性和可用性只能2選1。也就是說當網絡分區之後P是前提,決定了P之後纔有C和A的選擇。也就是說分區容錯性(Partition tolerance)我們是必須要實現的。

CAP定理的證明

關於CAP這三個特性我們就介紹完了,接下來我們試着證明一下爲什麼CAP不能同時滿足

img

爲了簡化證明的過程,我們假設整個集羣裏只有兩個N1和N2兩個節點,如下圖:

N1和N2當中各自有一個應用程序AB和數據庫,當系統滿足一致性的時候,我們認爲N1和N2數據庫中的數據保持一致。在滿足可用性的時候,我們認爲無論用戶訪問N1還是N2,都可以獲得正確的結果,在滿足分區容錯性的時候,我們認爲無論N1還是N2宕機或者是兩者的通信中斷,都不影響系統的運行。

我們假設一種極端情況,假設某個時刻N1和N2之間的網絡通信突然中斷了。如果系統滿足分區容錯性,那麼顯然可以支持這種異常。問題是在此前提下,一致性和可用性是否可以做到不受影響呢?

我們做個假象實驗,如下圖,突然某一時刻N1和N2之間的關聯斷開:

img

有用戶向N1發送了請求更改了數據,將數據庫從V0更新成了V1。由於網絡斷開,所以N2數據庫依然是V0,如果這個時候有一個請求發給了N2,但是N2並沒有辦法可以直接給出最新的結果V1,這個時候該怎麼辦呢?

這個時候無法兩種方法,一種是將錯就錯,將錯誤的V0數據返回給用戶。第二種是阻塞等待,等待網絡通信恢復,N2中的數據更新之後再返回給用戶。顯然前者犧牲了一致性,後者犧牲了可用性。

這個例子雖然簡單,但是說明的內容卻很重要。在分佈式系統當中,CAP三個特性我們是無法同時滿足的,必然要捨棄一個。三者捨棄一個,顯然排列組合一共有三種可能。

BASE理論

BASE理論由eBay架構師Dan Pritchett提出,在2008年上被分表爲論文,並且eBay給出了他們在實踐中總結的基於BASE理論的一套新的分佈式事務解決方案。

BASEBasically Available(基本可用)Soft-state(軟狀態)Eventually Consistent(最終一致性) 三個短語的縮寫。BASE理論是對CAP中一致性和可用性權衡的結果,其來源於對大規模互聯網系統分佈式實踐的總結,是基於CAP定理逐步演化而來的,它大大降低了我們對系統的要求。

BASE理論的核心思想

即使無法做到強一致性,但每個應用都可以根據自身業務特點,採用適當的方式來使系統達到最終一致性。也就是犧牲數據的一致性來滿足系統的高可用性,系統中一部分數據不可用或者不一致時,仍需要保持系統整體“主要可用”。

針對數據庫領域,BASE思想的主要實現是對業務數據進行拆分,讓不同的數據分佈在不同的機器上,以提升系統的可用性,當前主要有以下兩種做法:

  • 按功能劃分數據庫
  • 分片(如開源的Mycat、Amoeba等)。

由於拆分後會涉及分佈式事務問題,所以eBay在該BASE論文中提到了如何用最終一致性的思路來實現高性能的分佈式事務。

BASE理論三要素

BASE理論三要素

1. 基本可用

基本可用是指分佈式系統在出現不可預知故障的時候,允許損失部分可用性。但是,這絕不等價於系統不可用。

比如:

  • 響應時間上的損失:正常情況下,一個在線搜索引擎需要在0.5秒之內返回給用戶相應的查詢結果,但由於出現故障,查詢結果的響應時間增加了1~2秒
  • 系統功能上的損失:正常情況下,在一個電子商務網站上進行購物的時候,消費者幾乎能夠順利完成每一筆訂單,但是在一些節日大促購物高峯的時候,由於消費者的購物行爲激增,爲了保護購物系統的穩定性,部分消費者可能會被引導到一個降級頁面
2. 軟狀態

軟狀態指允許系統中的數據存在中間狀態,並認爲該中間狀態的存在不會影響系統的整體可用性,即允許系統在不同節點的數據副本之間進行數據同步的過程存在延時

3. 最終一致性

最終一致性強調的是系統中所有的數據副本,在經過一段時間的同步後,最終能夠達到一個一致的狀態。因此,最終一致性的本質是需要系統保證最終數據能夠達到一致,而不需要實時保證系統數據的強一致性。

數據結構與算法

冒泡排序

冒泡排序是一種簡單的排序算法。它重複地走訪過要排序的數列,依次比較兩個元素,如果它們的順序錯誤就把它們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。這個算法的名字由來是因爲越小的元素會經由交換慢慢“浮”到數列的頂端。

算法描述

  • 比較相鄰的元素。如果第一個比第二個大,就交換它們兩個;
  • 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對,這樣在最後的元素應該會是最大的數;
  • 針對所有的元素重複以上的步驟,除了最後一個;
  • 重複步驟1~3,直到排序完成。

動圖演示

冒泡排序

代碼實現

下面的排序算法統一使用的測試代碼如下,源碼GitHub鏈接

public static void main(String[] args) {
    int[] array = {3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48};
	// 只需要修改成對應的方法名就可以了
    bubbleSort(array);

    System.out.println(Arrays.toString(array));
}
/**
 * Description:冒泡排序
 *
 * @param array 需要排序的數組
 * @author JourWon
 * @date 2019/7/11 9:54
 */
public static void bubbleSort(int[] array) {
	if (array == null || array.length <= 1) {
		return;
	}

	int length = array.length;

	// 外層循環控制比較輪數i
	for (int i = 0; i < length; i++) {
		// 內層循環控制每一輪比較次數,每進行一輪排序都會找出一個較大值
		// (array.length - 1)防止索引越界,(array.length - 1 - i)減少比較次數
		for (int j = 0; j < length - 1 - i; j++) {
			// 前面的數大於後面的數就進行交換
			if (array[j] > array[j + 1]) {
				int temp = array[j + 1];
				array[j + 1] = array[j];
				array[j] = temp;
			}
		}
	}

}

算法分析

最佳情況:T(n) = O(n) 最差情況:T(n) = O(n2) 平均情況:T(n) = O(n2)

選擇排序

表現最穩定的排序算法之一,因爲無論什麼數據進去都是O(n2)的時間複雜度,所以用到它的時候,數據規模越小越好。唯一的好處可能就是不佔用額外的內存空間了吧。理論上講,選擇排序可能也是平時排序一般人想到的最多的排序方法了吧。

選擇排序(Selection-sort)是一種簡單直觀的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。

算法描述

n個記錄的直接選擇排序可經過n-1趟直接選擇排序得到有序結果。具體算法描述如下:

  • 初始狀態:無序區爲R[1…n],有序區爲空;
  • 第i趟排序(i=1,2,3…n-1)開始時,當前有序區和無序區分別爲R[1…i-1]和R(i…n)。該趟排序從當前無序區中-選出關鍵字最小的記錄 R[k],將它與無序區的第1個記錄R交換,使R[1…i]和R[i+1…n)分別變爲記錄個數增加1個的新有序區和記錄個數減少1個的新無序區;
  • n-1趟結束,數組有序化了。

動圖演示

選擇排序

代碼實現

下面的排序算法統一使用的測試代碼如下,源碼GitHub鏈接

public static void main(String[] args) {
    int[] array = {3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48};
	// 只需要修改成對應的方法名就可以了
    selectionSort(array);

    System.out.println(Arrays.toString(array));
}
/**
 * Description: 選擇排序
 *
 * @param array
 * @return void
 * @author JourWon
 * @date 2019/7/11 23:31
 */
public static void selectionSort(int[] array) {
	if (array == null || array.length <= 1) {
		return;
	}

	int length = array.length;

	for (int i = 0; i < length - 1; i++) {
		// 保存最小數的索引
		int minIndex = i;

		for (int j = i + 1; j < length; j++) {
			// 找到最小的數
			if (array[j] < array[minIndex]) {
				minIndex = j;
			}
		}

		// 交換元素位置
		if (i != minIndex) {
			swap(array, minIndex, i);
		}
	}

}

/**
 * Description: 交換元素位置
 *
 * @param array
 * @param a
 * @param b
 * @return void
 * @author JourWon
 * @date 2019/7/11 17:57
 */
private static void swap(int[] array, int a, int b) {
	int temp = array[a];
	array[a] = array[b];
	array[b] = temp;
}

算法分析

最佳情況:T(n) = O(n2) 最差情況:T(n) = O(n2) 平均情況:T(n) = O(n2)

快速排序

快速排序的基本思想:通過一趟排序將待排記錄分隔成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分的關鍵字小,則可分別對這兩部分記錄繼續進行排序,以達到整個序列有序。

算法描述

快速排序使用分治法來把一個串(list)分爲兩個子串(sub-lists)。具體算法描述如下:

  • 從數列中挑出一個元素,稱爲 “基準”(pivot);
  • 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的後面(相同的數可以到任一邊)。在這個分區退出之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作;
  • 遞歸地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序。

動圖演示

快速排序

代碼實現

下面的排序算法統一使用的測試代碼如下,源碼GitHub鏈接

public static void main(String[] args) {
    int[] array = {3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48};
	// 只需要修改成對應的方法名就可以了
    quickSort(array);

    System.out.println(Arrays.toString(array));
}
/**
 * Description: 快速排序
 *
 * @param array
 * @return void
 * @author JourWon
 * @date 2019/7/11 23:39
 */
public static void quickSort(int[] array) {
	quickSort(array, 0, array.length - 1);
}


private static void quickSort(int[] array, int left, int right) {
	if (array == null || left >= right || array.length <= 1) {
		return;
	}
	int mid = partition(array, left, right);
	quickSort(array, left, mid);
	quickSort(array, mid + 1, right);
}


private static int partition(int[] array, int left, int right) {
	int temp = array[left];
	while (right > left) {
		// 先判斷基準數和後面的數依次比較
		while (temp <= array[right] && left < right) {
			--right;
		}
		// 當基準數大於了 arr[left],則填坑
		if (left < right) {
			array[left] = array[right];
			++left;
		}
		// 現在是 arr[right] 需要填坑了
		while (temp >= array[left] && left < right) {
			++left;
		}
		if (left < right) {
			array[right] = array[left];
			--right;
		}
	}
	array[left] = temp;
	return left;
}

算法分析

最佳情況:T(n) = O(nlogn) 最差情況:T(n) = O(n2) 平均情況:T(n) = O(nlogn)

遞歸

什麼叫遞歸

遞歸函數就是直接或間接調用自身的函數,也就是自身調用自己。

一般什麼時候使用遞歸?

遞歸是常用的編程技術,其基本思想就是“自己調用自己”,一個使用遞歸技術的方法即是直接或間接的調用自身的方法。遞歸方法實際上體現了“以此類推”、“用同樣的步驟重複”這樣的思想。

還有些數據結構如二叉樹,結構本身固有遞歸特性;此外,有一類問題,其本身沒有明顯的遞歸結構,但用遞歸程序求解比其他方法更容易編寫程序。

需滿足的兩個條件

  1. 有反覆執行的過程(調用自身)
  2. 有跳出反覆執行過程的條件(遞歸出口)

經典問題:階乘

遞歸階乘n! = n * (n-1) * (n-2) * ...* 1(n>0)
public static Integer recursionMulity(Integer n) {
    if (n == 1) {
        return 1;
    }
    return n * recursionMulity(n - 1);
}

經典問題:不死神兔(斐波那契數列)

3個月起每個月都生一對兔子,小兔子長到第三個月後每個月又生一對兔子,假如兔子都不死,問每個月的兔子總數爲多少?

分析:首先我們要明白題目的意思指的是每個月的兔子總對數;假設將兔子分爲小中大三種,兔子從出生後三個月後每個月就會生出一對兔子,

那麼我們假定第一個月的兔子爲小兔子,第二個月爲中兔子,第三個月之後就爲大兔子,那麼第一個月分別有1、0、0,第二個月分別爲0、1、0,

第三個月分別爲1、0、1,第四個月分別爲,1、1、1,第五個月分別爲2、1、2,第六個月分別爲3、2、3,第七個月分別爲5、3、5……

兔子總數分別爲:1、1、2、3、5、8、13……

於是得出了一個規律,從第三個月起,後面的兔子總數都等於前面兩個月的兔子總數之和,即爲斐波那契數列。

public static int fib(int mon) {
    if (mon < 2) {
        return 1;
    } else {
        return fib(mon - 1) + fib(mon - 2);
    }
}

二分查找

在數組[130,150,170,190,210,230,250,270,290,310]中查找數字190,紅色爲二分線(折半線),灰色爲查找區域,黑色爲排除區域。

img二分查找也稱折半查找(Binary Search),它是一種效率較高的查找方法,前提是數據結構必須先排好序,時間複雜度可以表示O(h)=O(log2n),以2爲底,n的對數。其缺點是要求待查表爲有序表,且插入刪除困難。

左加右不加,找右縮左,找左縮右

public class BinarySearch {
    public static void main(String[] args) {
        int[] arr = {5, 12, 23, 43, 66, 98, 100};
        System.out.println(binarySort(arr, 23));
    }

    /**
     * 循環實現二分查找
     *
     * @param arr
     * @param key
     * @return
     */
    public static int binarySearch(int[] arr, int key) {
        //第一個下標
        int low = 0;
        //最後一個下標
        int high = arr.length - 1;
        int mid = 0;
        //防越界
        if (key < arr[low] || key > arr[high] || low > high) {
            return -1;
        }
        while (low <= high) {
            mid = (low + high) >>> 1;
            if (key < arr[mid]) {
                high = mid - 1;
            } else if (key > arr[mid]) {
                low = mid + 1;
            } else {
                return mid;
            }
        }
        return -1;
    }
}

二分查找中中間值的計算

這是一個經典的話題,如何計算二分查找中的中值?大家一般給出了兩種計算方法:

  • 算法一: mid = (low + high) / 2
  • 算法二: mid = low + (high – low)/2

乍看起來,算法一簡潔,算法二提取之後,跟算法一沒有什麼區別。但是實際上,區別是存在的。算法一的做法,在極端情況下,(low + high)存在着溢出的風險,進而得到錯誤的mid結果,導致程序錯誤。而算法二能夠保證計算出來的mid,一定大於low,小於high,不存在溢出的問題。

一致性Hash算法

概述

一致性Hash是一種特殊的Hash算法,由於其均衡性、持久性的映射特點,被廣泛的應用於負載均衡領域和分佈式存儲,如nginx和memcached都採用了一致性Hash來作爲集羣負載均衡的方案。

普通的Hash函數最大的作用是散列,或者說是將一系列在形式上具有相似性質的數據,打散成隨機的、均勻分佈的數據。不難發現,這樣的Hash只要集羣的數量N發生變化,之前的所有Hash映射就會全部失效。如果集羣中的每個機器提供的服務沒有差別,倒不會產生什麼影響,但對於分佈式緩存這樣的系統而言,映射全部失效就意味着之前的緩存全部失效,後果將會是災難性的。一致性Hash通過構建環狀的Hash空間代替線性Hash空間的方法解決了這個問題。

良好的分佈式cahce系統中的一致性hash算法應該滿足以下幾個方面:

  • 平衡性(Balance)

平衡性是指哈希的結果能夠儘可能分佈到所有的緩衝中去,這樣可以使得所有的緩衝空間都得到利用。很多哈希算法都能夠滿足這一條件。

  • 單調性(Monotonicity)

單調性是指如果已經有一些內容通過哈希分派到了相應的緩衝中,又有新的緩衝區加入到系統中,那麼哈希的結果應能夠保證原有已分配的內容可以被映射到新的緩衝區中去,而不會被映射到舊的緩衝集合中的其他緩衝區。

  • 分散性(Spread)

在分佈式環境中,終端有可能看不到所有的緩衝,而是隻能看到其中的一部分。當終端希望通過哈希過程將內容映射到緩衝上時,由於不同終端所見的緩衝範圍有可能不同,從而導致哈希的結果不一致,最終的結果是相同的內容被不同的終端映射到不同的緩衝區中。這種情況顯然是應該避免的,因爲它導致相同內容被存儲到不同緩衝中去,降低了系統存儲的效率。分散性的定義就是上述情況發生的嚴重程度。好的哈希算法應能夠儘量避免不一致的情況發生,也就是儘量降低分散性。

  • 負載(Load)

負載問題實際上是從另一個角度看待分散性問題。既然不同的終端可能將相同的內容映射到不同的緩衝區中,那麼對於一個特定的緩衝區而言,也可能被不同的用戶映射爲不同的內容。與分散性一樣,這種情況也是應當避免的,因此好的哈希算法應能夠儘量降低緩衝的負荷。

  • 平滑性(Smoothness)

平滑性是指緩存服務器的數目平滑改變和緩存對象的平滑改變是一致的。

一致性Hash算法原理

簡單來說,一致性哈希將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間爲0-232-1(即哈希值是一個32位無符號整形),整個哈希空間環如下:整個空間按順時針方向組織。0和232-1在零點中方向重合。

下一步將各個服務器使用Hash進行一次哈希,具體可以選擇服務器的ip或主機名作爲關鍵字進行哈希,這樣每臺機器就能確定其在哈希環上的位置,這裏假設將上文中四臺服務器使用ip地址哈希後在環空間的位置如下:

接下來使用如下算法定位數據訪問到相應服務器:將數據key使用相同的函數Hash計算出哈希值,並確定此數據在環上的位置,從此位置沿環順時針“行走”,第一臺遇到的服務器就是其應該定位到的服務器。

例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算後,在環空間上的位置如下:

根據一致性哈希算法,數據A會被定爲到Node A上,B被定爲到Node B上,C被定爲到Node C上,D被定爲到Node D上。

下面分析一致性哈希算法的容錯性和可擴展性。現假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性哈希算法中,如果一臺服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一臺服務器(即沿着逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響。

下面考慮另外一種情況,如果在系統中增加一臺服務器Node X,如下圖所示:

此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X 。一般的,在一致性哈希算法中,如果增加一臺服務器,則受影響的數據僅僅是新服務器到其環空間中前一臺服務器(即沿着逆時針方向行走遇到的第一臺服務器)之間數據,其它數據也不會受到影響。

綜上所述,一致性哈希算法對於節點的增減都只需重定位環空間中的一小部分數據,具有較好的容錯性和可擴展性。

Java代碼實現

public class ConsistentHash<T> {

    /**
     * 節點的複製因子,實際節點個數 * numberOfReplicas = 虛擬節點個數
     */
    private final int numberOfReplicas;
    /**
     * 存儲虛擬節點的hash值到真實節點的映射
     */
    private final SortedMap<Integer, T> circle = new TreeMap<Integer, T>();

    public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
        this.numberOfReplicas = numberOfReplicas;
        for (T node : nodes) {
            add(node);
        }
    }

    public void add(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            // 對於一個實際機器節點 node, 對應 numberOfReplicas 個虛擬節點
            /*
             * 不同的虛擬節點(i不同)有不同的hash值,但都對應同一個實際機器node
             * 虛擬node一般是均衡分佈在環上的,數據存儲在順時針方向的虛擬node上
             */
            String nodestr = node.toString() + i;
            int hashcode = nodestr.hashCode();
            System.out.println("hashcode:" + hashcode);
            circle.put(hashcode, node);

        }
    }

    public void remove(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            circle.remove((node.toString() + i).hashCode());
        }
    }


    /**
     * 獲得一個最近的順時針節點,根據給定的key 取Hash
     * 然後再取得順時針方向上最近的一個虛擬節點對應的實際節點
     * 再從實際節點中取得 數據
     *
     * @param key
     * @return
     */
    public T get(Object key) {
        if (circle.isEmpty()) {
            return null;
        }
        // node 用String來表示,獲得node在哈希環中的hashCode
        int hash = key.hashCode();
        System.out.println("hashcode----->:" + hash);
        //數據映射在兩臺虛擬機器所在環之間,就需要按順時針方向尋找機器
        if (!circle.containsKey(hash)) {
            SortedMap<Integer, T> tailMap = circle.tailMap(hash);
            hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
        }
        return circle.get(hash);
    }

    public long getSize() {
        return circle.size();
    }

    /**
     * 查看錶示整個哈希環中各個虛擬節點位置
     */
    public void testBalance() {
        //獲得TreeMap中所有的Key
        Set<Integer> sets = circle.keySet();
        //將獲得的Key集合排序
        SortedSet<Integer> sortedSets = new TreeSet<Integer>(sets);
        for (Integer hashCode : sortedSets) {
            System.out.println(hashCode);
        }

        System.out.println("----each location 's distance are follows: ----");
        /*
         * 查看相鄰兩個hashCode的差值
         */
        Iterator<Integer> it = sortedSets.iterator();
        Iterator<Integer> it2 = sortedSets.iterator();
        if (it2.hasNext()) {
            it2.next();
        }
        long keyPre, keyAfter;
        while (it.hasNext() && it2.hasNext()) {
            keyPre = it.next();
            keyAfter = it2.next();
            System.out.println(keyAfter - keyPre);
        }
    }

    public static void main(String[] args) {
        Set<String> nodes = new HashSet<String>();
        nodes.add("A");
        nodes.add("B");
        nodes.add("C");

        ConsistentHash<String> consistentHash = new ConsistentHash<String>(2, nodes);
        consistentHash.add("D");

        System.out.println("hash circle size: " + consistentHash.getSize());
        System.out.println("location of each node are follows: ");
        consistentHash.testBalance();

        String node = consistentHash.get("apple");
        System.out.println("node----------->:" + node);
    }

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