關於「 一致性哈希」你應該知道的事

「一致性哈希」是什麼

什麼是哈希算法呢,百度百科的定義以下:java

哈希算法將任意長度的二進制值映射爲較短的固定長度的二進制值,這個小的二進制值稱爲哈希值。哈希值是一段數據惟一且極其緊湊的數值表示形式。node

Hash算法存在必定的侷限性。例如,咱們有7臺服務器,經過咱們上一節的Hash算法將用戶id(天然數)做爲輸入,獲得一個服務器編號。而後將用戶的信息近似均勻地分配到這7臺服務器上,從而實現負載均衡。隨着用戶增多,咱們增長一臺服務器時,通常須要進行從新Hash操做,對各個服務器上的數據進行從新分配,此操做幾乎涉及全部服務器上的全部數據,工做量極大。git

那麼一致性哈希是什麼呢?程序員

一致性Hash算法(Consistent Hashing)是一種hash算法,它可以在Hash輸出空間發生變化時,引發最小的變更。github

一致性哈希算法在1997年由麻省理工學院提出,是一種特殊的哈希算法,目的是解決分佈式緩存的問題,解決了簡單哈希算法在分佈式哈希表中存在的動態伸縮等問題。算法

虛擬環形的結構來表示資源請求者(爲了敘述方便,後文將稱之爲「請求」)和服務器節點,這個環一般被稱做一個 hashring緩存

image.png

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

  • 平衡性(Balance)

平衡性是指哈希的結果可以儘量分佈到全部的緩衝中去,這樣可使得全部的緩衝空間都獲得利用。不少哈希算法都可以知足這一條件。tomcat

  • 單調性(Monotonicity)

單調性是指若是已經有一些內容經過哈希分派到了相應的緩衝中,又有新的緩衝區加入到系統中,那麼哈希的結果應可以保證原有已分配的內容能夠被映射到新的緩衝區中去,而不會被映射到舊的緩衝集合中的其餘緩衝區。簡單的哈希算法每每不能知足單調性的要求。哈希結果的變化意味着當緩衝空間發生變化時,全部的映射關係須要在系統內所有更新。服務器

  • 分散性(Spread)

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

  • 負載(Load)

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

  • 平滑性(Smoothness)

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

一致性hash算法思路

一致性hash時採用以下步驟:

  • 首先求出服務器(節點)的哈希值,並將其配置到0~2^32的圓(continuum)上。
  • 而後採用一樣的方法求出存儲數據的鍵的哈希值,並映射到相同的圓上。
  • 而後從數據映射到的位置開始順時針查找,將數據保存到找到的第一個服務器上。
  • 若是超過2^32仍然找不到服務器,就會保存到第一臺服務器上。

image.png

一致性hash存在的問題及虛擬節點的引入:

問題:若是服務器數特別少,不是均勻分佈在哈希環上,那麼當客戶端訪問時,容易形成大量的訪問積壓在一臺服務器上,形成數據傾斜問題,引發負載太高。

解決方案:引入虛擬節點的概念,對每個服務器節點計算依據特定規則計算多個Hash,分佈在哈希環上,當客戶端請求來時,若是順時針指定的爲某個虛擬節點,則交由真實的節點服務器處理請求。

image.png

  • 一致性哈希算法代碼
import java.util.SortedMap;
import java.util.TreeMap;

/** * 一致性hash算法(不含虛擬節點) */
public class ConsistentHashNoVirtual {
public static void main(String[] args) {
    //step1:定義server服務器的ip值映射到哈希環上
    String[] tomcatServers = new String[]{"123.111.0.0","123.101.3.1","111.20.35.2","123.98.26.3"};

    //使用sortedMap模擬哈希環
    SortedMap<Integer,String> serverMap = new TreeMap<Integer, String>();

    for(String tomcatServer:tomcatServers){
        //計算每一個服務器id的哈希值
        int serverHash = Math.abs(tomcatServer.hashCode());

        //將服務端哈希值以及其ip地址的對應關係存儲到sortedMap中
        serverMap.put(serverHash,tomcatServer);
    }

    //step2:針對客戶端的ip計算出對應的哈希值
    String[] clientServers = new String[]{"10.78.12.3","113.25.63.1","126.12.3.8"};

    for(String clientServer:clientServers){
        int clientHash = Math.abs(clientServer.hashCode());

        //step3:看客戶端的ip可以被哪一個服務器所處理
        //順時針獲取比這個客戶端ip的hash值大的服務
        SortedMap<Integer, String> integerStringSortedMap = serverMap.tailMap(clientHash);
        if(integerStringSortedMap.isEmpty()){
            //若是查詢出來爲空的,那麼取哈希環上的第一個值
            Integer integer = serverMap.firstKey();

            System.out.println("客戶端:"+clientServer+"路由到的tomcat服務器ip爲"+serverMap.get(integer));
        }else{
            Integer integer = integerStringSortedMap.firstKey();

            System.out.println("客戶端:"+clientServer+"路由到的tomcat服務器ip爲"+serverMap.get(integer));
        }
    }
}
}

複製代碼
  • 含虛擬節點的哈希算法
import java.util.SortedMap;
import java.util.TreeMap;

/** * 一致性hash算法(包含虛擬節點) */
public class ConsistentHashWithVirtual {
public static void main(String[] args) {
    //step1:定義server服務器的ip值映射到哈希環上
    String[] tomcatServers = new String[]{"123.111.0.0","123.101.3.1","111.20.35.2","123.98.26.3"};

    //使用sortedMap模擬哈希環
    SortedMap<Integer,String> serverMap = new TreeMap<Integer, String>();

    //定義虛擬節點的數量
    int virtualCount = 3;

    for(String tomcatServer:tomcatServers){
        //計算每一個服務器id的哈希值
        int serverHash = Math.abs(tomcatServer.hashCode());

        //將服務端哈希值以及其ip地址的對應關係存儲到sortedMap中
        serverMap.put(serverHash,tomcatServer);

        //配置虛擬節點
        for(int i = 0;i<virtualCount;i++){
            //計算虛擬節點的哈希值
            int virtualHash = Math.abs((tomcatServer+"#"+i).hashCode());

            //將虛擬節點哈希值與服務器關係存儲到
            serverMap.put(virtualHash,"由虛擬節點"+i+"映射過來的請求:"+tomcatServer);
        }
    }

    //step2:針對客戶端的ip計算出對應的哈希值
    String[] clientServers = new String[]{"10.78.12.3","113.25.63.1","126.12.3.8"};

    for(String clientServer:clientServers){
        int clientHash = Math.abs(clientServer.hashCode());

        //step3:看客戶端的ip可以被哪一個服務器所處理
        //順時針獲取比這個客戶端ip的hash值大的服務
        SortedMap<Integer, String> integerStringSortedMap = serverMap.tailMap(clientHash);
        if(integerStringSortedMap.isEmpty()){
            //若是查詢出來爲空的,那麼取哈希環上的第一個值
            Integer integer = serverMap.firstKey();

            System.out.println("客戶端:"+clientServer+"路由到的tomcat服務器ip爲"+serverMap.get(integer));
        }else{
            Integer integer = integerStringSortedMap.firstKey();

            System.out.println("客戶端:"+clientServer+"路由到的tomcat服務器ip爲"+serverMap.get(integer));
        }
    }
}
}
複製代碼

一致性哈希算法通常用來幹什麼

通常咱們在項目的負載均衡上要求資源被均勻的分配到全部的服務器節點上,同時,還須要對資源的請求能迅速的路由到具體的節點,例如:

  • 咱們在作RPC服務的時候,會常常部署多臺服務器,然而有時有這樣的需求就是,咱們但願將同一類型的請求路由到同一臺機器上,這個時候就能夠用一致性hash算法來實現。

  • MemCache集羣,要求存儲數據均勻的放到集羣中的各個節點上,訪問這些數據時能快速的路由到集羣中對應存放該數據的節點上;而且要求增刪節點對整個集羣的影響很小,不至於有大的動盪形成總體負載的不穩定,這個時候也是能夠用一致性hash算法。

常見的「一致性哈希」算法

KETAMA_HASH算法

該算法最初是由Last.fm的程序員實現的並獲得了普遍的應用,一些開源框架譬如spymemcached,twemproxy等都內置了該算法的實現。

咱們從spymemcached的源碼出發,分析Ketama算法的具體實現。

在類KetamaNodeLocator.java裏有個setKetamaNodes()方法負責一致性哈希環的初始化工做, 代碼以下:

protected void setKetamaNodes(List<MemcachedNode> nodes) {
        TreeMap<Long, MemcachedNode> newNodeMap = new TreeMap<Long, MemcachedNode>();
        int numReps = config.getNodeRepetitions();

        for (MemcachedNode node : nodes) {
          // Ketama does some special work with md5 where it reuses chunks.
          if (hashAlg == DefaultHashAlgorithm.KETAMA_HASH) {
            for (int i = 0; i < numReps / 4; i++) {
              byte[] digest = DefaultHashAlgorithm.computeMd5(config.getKeyForNode(node, i));
              for (int h = 0; h < 4; h++) {
                Long k = ((long) (digest[3 + h * 4] & 0xFF) << 24)
                        | ((long) (digest[2 + h * 4] & 0xFF) << 16)
                        | ((long) (digest[1 + h * 4] & 0xFF) << 8)
                        | (digest[h * 4] & 0xFF);

                newNodeMap.put(k, node);
                getLogger().debug("Adding node %s in position %d", node, k);
              }
            }
          } else {
            for (int i = 0; i < numReps; i++) {
              newNodeMap.put(hashAlg.hash(config.getKeyForNode(node, i)), node);
            }
          }
        }
        assert newNodeMap.size() == numReps * nodes.size();
        ketamaNodes = newNodeMap;
    }
複製代碼

MurmurHash算法

MurmurHash 是一種非加密型哈希函數,適用於通常的哈希檢索操做。 由Austin Appleby在2008年發明, 並出現了多個變種,都已經發布到了公有領域(public domain)。與其它流行的哈希函數相比,對於規律性較強的key,MurmurHash的隨機分佈特徵表現更良好。

 Redis在實現字典時用到了兩種不一樣的哈希算法,MurmurHash即是其中一種(另外一種是djb).

代碼來自GitHub: github.com/aappleby/sm…

Murmurhash3.h

//-----------------------------------------------------------------------------
// MurmurHash3 was written by Austin Appleby, and is placed in the public
// domain. The author hereby disclaims copyright to this source code.
 
#ifndef _MURMURHASH3_H_
#define _MURMURHASH3_H_
 
//-----------------------------------------------------------------------------
// Platform-specific functions and macros
 
// Microsoft Visual Studio
 
#if defined(_MSC_VER) && (_MSC_VER < 1600)
 
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef unsigned __int64 uint64_t;
 
// Other compilers
 
#else // defined(_MSC_VER)
 
#include <stdint.h>
 
#endif // !defined(_MSC_VER)
 
//-----------------------------------------------------------------------------
 
void MurmurHash3_x86_32 ( const void * key, int len, uint32_t seed, void * out );
 
void MurmurHash3_x86_128 ( const void * key, int len, uint32_t seed, void * out );
 
void MurmurHash3_x64_128 ( const void * key, int len, uint32_t seed, void * out );
 
//-----------------------------------------------------------------------------
 
#endif // _MURMURHASH3_H_
複製代碼

Murmurhash3.c

//-----------------------------------------------------------------------------
// MurmurHash3 was written by Austin Appleby, and is placed in the public
// domain. The author hereby disclaims copyright to this source code.
 
// Note - The x86 and x64 versions do _not_ produce the same results, as the
// algorithms are optimized for their respective platforms. You can still
// compile and run any of them on any platform, but your performance with the
// non-native version will be less than optimal.
// 
// github : https://github.com/aappleby/smhasher
 
#include "MurmurHash3.h"
 
//-----------------------------------------------------------------------------
// Platform-specific functions and macros
 
// Microsoft Visual Studio
 
#if defined(_MSC_VER)
 
#define FORCE_INLINE __forceinline
 
#include <stdlib.h>
 
#define ROTL32(x,y) _rotl(x,y)
#define ROTL64(x,y) _rotl64(x,y)
 
#define BIG_CONSTANT(x) (x)
 
// Other compilers
 
#else // defined(_MSC_VER)
#define FORCE_INLINE inline __attribute__((always_inline))
 
inline static uint32_t rotl32 ( uint32_t x, int8_t r ) {
    return (x << r) | (x >> (32 - r));
}
 
inline static uint64_t rotl64 ( uint64_t x, int8_t r ) {
    return (x << r) | (x >> (64 - r));
}
 
#define ROTL32(x,y) rotl32(x,y)
#define ROTL64(x,y) rotl64(x,y)
 
#define BIG_CONSTANT(x) (x##LLU)
 
#endif // !defined(_MSC_VER)
 
//-----------------------------------------------------------------------------
// Block read - if your platform needs to do endian-swapping or can only
// handle aligned reads, do the conversion here
 
FORCE_INLINE uint32_t getblock32 ( const uint32_t * p, int i ) {
    return p[i];
}
 
FORCE_INLINE uint64_t getblock64 ( const uint64_t * p, int i ) {
    return p[i];
}
 
//-----------------------------------------------------------------------------
// Finalization mix - force all bits of a hash block to avalanche
 
FORCE_INLINE uint32_t fmix32 ( uint32_t h ) {
    h ^= h >> 16;
    h *= 0x85ebca6b;
    h ^= h >> 13;
    h *= 0xc2b2ae35;
    h ^= h >> 16;
 
    return h;
}
 
//----------
 
FORCE_INLINE uint64_t fmix64 ( uint64_t k ) {
    k ^= k >> 33;
    k *= BIG_CONSTANT(0xff51afd7ed558ccd);
    k ^= k >> 33;
    k *= BIG_CONSTANT(0xc4ceb9fe1a85ec53);
    k ^= k >> 33;
 
    return k;
}
 
//-----------------------------------------------------------------------------
 
void MurmurHash3_x86_32 ( const void * key, int len, ¦ uint32_t seed, void * out ) {
    const uint8_t * data = (const uint8_t*)key;
    const int nblocks = len / 4;
 
    uint32_t h1 = seed;
 
    const uint32_t c1 = 0xcc9e2d51;
    const uint32_t c2 = 0x1b873593;
 
    //----------
    // body
 
    const uint32_t * blocks = (const uint32_t *)(data + nblocks*4);
 
    for(int i = -nblocks; i; i++)
    {
        uint32_t k1 = getblock32(blocks,i);
 
        k1 *= c1;
        k1 = ROTL32(k1,15);
        k1 *= c2;
 
        h1 ^= k1;
        h1 = ROTL32(h1,13);
        h1 = h1*5+0xe6546b64;
    }
 
    //----------
    // tail
 
    const uint8_t * tail = (const uint8_t*)(data + nblocks*4);
 
    uint32_t k1 = 0;
 
    switch(len & 3)
    {
        case 3: k1 ^= tail[2] << 16;
        case 2: k1 ^= tail[1] << 8;
        case 1: k1 ^= tail[0];
                k1 *= c1; k1 = ROTL32(k1,15); k1 *= c2; h1 ^= k1;
    };
 
    //----------
    // finalization
 
    h1 ^= len;
 
    h1 = fmix32(h1);
 
    *(uint32_t*)out = h1;
}
 
//-----------------------------------------------------------------------------
 
void MurmurHash3_x86_128 ( const void * key, const int len, uint32_t seed, void * out ) {
    const uint8_t * data = (const uint8_t*)key;
    const int nblocks = len / 16;
 
    uint32_t h1 = seed;
    uint32_t h2 = seed;
    uint32_t h3 = seed;
    uint32_t h4 = seed;
 
    const uint32_t c1 = 0x239b961b;
    const uint32_t c2 = 0xab0e9789;
    const uint32_t c3 = 0x38b34ae5;
    const uint32_t c4 = 0xa1e38b93;
 
    //----------
    // body
 
    const uint32_t * blocks = (const uint32_t *)(data + nblocks*16);
 
    for(int i = -nblocks; i; i++)
    {
        uint32_t k1 = getblock32(blocks,i*4+0);
        uint32_t k2 = getblock32(blocks,i*4+1);
        uint32_t k3 = getblock32(blocks,i*4+2);
        uint32_t k4 = getblock32(blocks,i*4+3);
 
        k1 *= c1; k1  = ROTL32(k1,15); k1 *= c2; h1 ^= k1;
 
        h1 = ROTL32(h1,19); h1 += h2; h1 = h1*5+0x561ccd1b;
 
        k2 *= c2; k2  = ROTL32(k2,16); k2 *= c3; h2 ^= k2;
 
        h2 = ROTL32(h2,17); h2 += h3; h2 = h2*5+0x0bcaa747;
 
        k3 *= c3; k3  = ROTL32(k3,17); k3 *= c4; h3 ^= k3;
 
        h3 = ROTL32(h3,15); h3 += h4; h3 = h3*5+0x96cd1c35;
 
        k4 *= c4; k4  = ROTL32(k4,18); k4 *= c1; h4 ^= k4;
 
        h4 = ROTL32(h4,13); h4 += h1; h4 = h4*5+0x32ac3b17;
    }
 
    //----------
    // tail
 
    const uint8_t * tail = (const uint8_t*)(data + nblocks*16);
 
    uint32_t k1 = 0;
    uint32_t k2 = 0;
    uint32_t k3 = 0;
    uint32_t k4 = 0;
 
    switch(len & 15)
    {
        case 15: k4 ^= tail[14] << 16;
        case 14: k4 ^= tail[13] << 8;
        case 13: k4 ^= tail[12] << 0;
                 k4 *= c4; k4  = ROTL32(k4,18); k4 *= c1; h4 ^= k4;
 
        case 12: k3 ^= tail[11] << 24;
        case 11: k3 ^= tail[10] << 16;
        case 10: k3 ^= tail[ 9] << 8;
        case  9: k3 ^= tail[ 8] << 0;
                 k3 *= c3; k3  = ROTL32(k3,17); k3 *= c4; h3 ^= k3;
 
        case  8: k2 ^= tail[ 7] << 24;
        case  7: k2 ^= tail[ 6] << 16;
        case  6: k2 ^= tail[ 5] << 8;
        case  5: k2 ^= tail[ 4] << 0;
                 k2 *= c2; k2  = ROTL32(k2,16); k2 *= c3; h2 ^= k2;
 
        case  4: k1 ^= tail[ 3] << 24;
        case  3: k1 ^= tail[ 2] << 16;
        case  2: k1 ^= tail[ 1] << 8;
        case  1: k1 ^= tail[ 0] << 0;
                 k1 *= c1; k1  = ROTL32(k1,15); k1 *= c2; h1 ^= k1;
    };
 
    //----------
    // finalization
 
    h1 ^= len; h2 ^= len; h3 ^= len; h4 ^= len;
 
    h1 += h2; h1 += h3; h1 += h4;
    h2 += h1; h3 += h1; h4 += h1;
 
    h1 = fmix32(h1);
    h2 = fmix32(h2);
    h3 = fmix32(h3);
    h4 = fmix32(h4);
 
    h1 += h2; h1 += h3; h1 += h4;
    h2 += h1; h3 += h1; h4 += h1;
 
    ((uint32_t*)out)[0] = h1;
    ((uint32_t*)out)[1] = h2;
    ((uint32_t*)out)[2] = h3;
    ((uint32_t*)out)[3] = h4;
}
 
//-----------------------------------------------------------------------------
 
void MurmurHash3_x64_128 ( const void * key, const int len, const uint32_t seed, void * out ) {
    const uint8_t * data = (const uint8_t*)key;
    const int nblocks = len / 16;
 
    uint64_t h1 = seed;
    uint64_t h2 = seed;
 
    const uint64_t c1 = BIG_CONSTANT(0x87c37b91114253d5);
    const uint64_t c2 = BIG_CONSTANT(0x4cf5ad432745937f);
 
    //----------
    // body
 
    const uint64_t * blocks = (const uint64_t *)(data);
 
    for(int i = 0; i < nblocks; i++)
    {
        uint64_t k1 = getblock64(blocks,i*2+0);
        uint64_t k2 = getblock64(blocks,i*2+1);
 
        k1 *= c1; k1  = ROTL64(k1,31); k1 *= c2; h1 ^= k1;
 
        h1 = ROTL64(h1,27); h1 += h2; h1 = h1*5+0x52dce729;
 
        k2 *= c2; k2  = ROTL64(k2,33); k2 *= c1; h2 ^= k2;
 
        h2 = ROTL64(h2,31); h2 += h1; h2 = h2*5+0x38495ab5;
    }
 
    //----------
    // tail
 
    const uint8_t * tail = (const uint8_t*)(data + nblocks*16);
 
    uint64_t k1 = 0;
    uint64_t k2 = 0;
 
    switch(len & 15)
    {
        case 15: k2 ^= ((uint64_t)tail[14]) << 48;
        case 14: k2 ^= ((uint64_t)tail[13]) << 40;
        case 13: k2 ^= ((uint64_t)tail[12]) << 32;
        case 12: k2 ^= ((uint64_t)tail[11]) << 24;
        case 11: k2 ^= ((uint64_t)tail[10]) << 16;
        case 10: k2 ^= ((uint64_t)tail[ 9]) << 8;
        case  9: k2 ^= ((uint64_t)tail[ 8]) << 0;
                 k2 *= c2; k2  = ROTL64(k2,33); k2 *= c1; h2 ^= k2;
 
        case  8: k1 ^= ((uint64_t)tail[ 7]) << 56;
        case  7: k1 ^= ((uint64_t)tail[ 6]) << 48;
        case  6: k1 ^= ((uint64_t)tail[ 5]) << 40;
        case  5: k1 ^= ((uint64_t)tail[ 4]) << 32;
        case  4: k1 ^= ((uint64_t)tail[ 3]) << 24;
        case  3: k1 ^= ((uint64_t)tail[ 2]) << 16;
        case  2: k1 ^= ((uint64_t)tail[ 1]) << 8;
        case  1: k1 ^= ((uint64_t)tail[ 0]) << 0;
                 k1 *= c1; k1  = ROTL64(k1,31); k1 *= c2; h1 ^= k1;
    };
 
    //----------
    // finalization
 
    h1 ^= len; h2 ^= len;
 
    h1 += h2;
    h2 += h1;
 
    h1 = fmix64(h1);
    h2 = fmix64(h2);
 
    h1 += h2;
    h2 += h1;
 
    ((uint64_t*)out)[0] = h1;
    ((uint64_t*)out)[1] = h2;
}
 
//-----------------------------------------------------------------------------
#if 1
 
#include <string.h>
#include <stdio.h>
#define SEED 0x97c29b3a
 
int main() {
    const char *str="abcdefghijklmn";
 
    uint32_t out1;
    MurmurHash3_x86_32(str, strlen(str), SEED, &out1);
    printf("%u\n", out1);
 
    uint32_t out2[4];
    MurmurHash3_x86_128(str, strlen(str), SEED, out2);
    printf("%u, %u, %u, %u\n", out2[0], out2[1], out2[2], out2[3]);
 
    uint64_t out3[2];
    MurmurHash3_x64_128(str, strlen(str), SEED, out3);
    printf("%lu, %lu\n", out3[0], out3[1]);
 
    return 0;
}
 
#endif
複製代碼
  • MurmurHash3_x86_32 將key 哈希32位的正整數
  • MurmurHash3_x86_128 將key 哈希128位的4個無符號位32整數,x86是32位的
  • MurmurHash3_x64_128 將key 哈希128位的2個無符號64位整數,x64是64位的

參考資料

juejin.cn/post/684490…

zhuanlan.zhihu.com/p/183074177

www.zhihu.com/question/23…

zhuanlan.zhihu.com/p/203269133

zhuanlan.zhihu.com/p/95683526

zhuanlan.zhihu.com/p/346625682

zhuanlan.zhihu.com/p/51296598

www.jianshu.com/p/f78a31725…

blog.csdn.net/qigaohua/ar…

相關文章
相關標籤/搜索