Memcached - In Action

Memcached

標籤 : Java與NoSQL


With Java

比較知名的Java Memcached客戶端有三款:Java-Memcached-ClientXMemcached以及Spymemcached, 其中以XMemcached性能最好, 且維護較穩定/版本較新:

<dependency>
    <groupId>com.googlecode.xmemcached</groupId>
    <artifactId>xmemcached</artifactId>
    <version>2.0.0</version>
</dependency>

XMemcached以及其他兩款Memcached客戶端的詳細信息可參考博客XMemcached-一個新的開源Java memcached客戶端Java幾個Memcached連接客戶端對比選擇.


實踐

任何技術都有其最適用的場景,只有在合適的場景下,才能發揮最好的效果.Memcached使用內存讀寫數據,速度比DB和文件系統快得多, 因此,Memcached的常用場景有:

  • 緩存DB查詢數據: 作爲緩存“保護”數據庫, 防止頻繁的讀寫帶給DB過大的壓力;
  • 中繼MySQL主從延遲: 利用其“讀寫快”特點實現主從數據庫的消息同步.

緩存DB查詢數據

通過Memcached緩存數據庫查詢結果,減少DB訪問次數,以提高動態Web應用響應速度:

  • JDBC模擬Memcached緩存DB數據:
/**
 * @author jifang.
 * @since 2016/6/13 20:08.
 */
public class MemcachedDAO {

    private static final int _1M = 60 * 1000;

    private static final DataSource dataSource;

    private static final MemcachedClient mc;

    static {
        Properties properties = new Properties();
        try {
            properties.load(ClassLoader.getSystemResourceAsStream("db.properties"));
        } catch (IOException ignored) {
        }

        /** 初始化連接池 **/
        HikariConfig config = new HikariConfig();
        config.setDriverClassName(properties.getProperty("mysql.driver.class"));
        config.setJdbcUrl(properties.getProperty("mysql.url"));
        config.setUsername(properties.getProperty("mysql.user"));
        config.setPassword(properties.getProperty("mysql.password"));
        config.setMaximumPoolSize(Integer.valueOf(properties.getProperty("pool.max.size")));
        config.setMinimumIdle(Integer.valueOf(properties.getProperty("pool.min.size")));
        config.setIdleTimeout(Integer.valueOf(properties.getProperty("pool.max.idle_time")));
        config.setMaxLifetime(Integer.valueOf(properties.getProperty("pool.max.life_time")));
        dataSource = new HikariDataSource(config);

        /** 初始化Memcached **/
        try {
            mc = new XMemcachedClientBuilder(properties.getProperty("memcached.servers")).build();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public List<Map<String, Object>> executeQuery(String sql) {
        List<Map<String, Object>> result;
        try {
            /** 首先請求MC **/
            String key = sql.replace(' ', '-');
            result = mc.get(key);

            // 如果key未命中, 再請求DB
            if (result == null || result.isEmpty()) {
                ResultSet resultSet = dataSource.getConnection().createStatement().executeQuery(sql);

                /** 獲得列數/列名 **/
                ResultSetMetaData meta = resultSet.getMetaData();
                int columnCount = meta.getColumnCount();
                List<String> columnName = new ArrayList<>();
                for (int i = 1; i <= columnCount; ++i) {
                    columnName.add(meta.getColumnName(i));
                }

                /** 填充實體 **/
                result = new ArrayList<>();
                while (resultSet.next()) {
                    Map<String, Object> entity = new HashMap<>(columnCount);
                    for (String name : columnName) {
                        entity.put(name, resultSet.getObject(name));
                    }
                    result.add(entity);
                }

                /** 寫入MC **/
                mc.set(key, _1M, result);
            }
        } catch (TimeoutException | InterruptedException | MemcachedException | SQLException e) {
            throw new RuntimeException(e);
        }
        return result;
    }

    public static void main(String[] args) {
        MemcachedDAO dao = new MemcachedDAO();
        List<Map<String, Object>> execute = dao.executeQuery("select * from orders");
        System.out.println(execute);
    }
}

注: 代碼僅供展示DB緩存思想,因爲一般項目很少會直接使用JDBC操作DB,而是會選用像MyBatis之類的ORM框架代替之,而這類框架框架一般也會開放接口出來實現與緩存產品的整合(如MyBatis開放出一個org.apache.ibatis.cache.Cache接口,通過實現該接口,可將Memcached與MyBatis整合, 細節可參考博客MyBatis與Memcached集成.


中繼MySQL主從延遲

MySQL在做replication時,主從複製時會由一段時間延遲,尤其是主從服務器分處於異地機房時,這種情況更加明顯.FaceBook官方的一篇技術文章提到:其加州的數據中心到弗吉尼亞州數據中心的主從同步延遲達到70MS. 考慮以下場景:

  • 用戶U購買電子書B:insert into Master (U,B);
  • 用戶U觀看電子書B:select 購買記錄 [user='A',book='B'] from Slave.
    由於主從延遲的存在,第②步中無記錄,用戶無權觀看該書.

此時可以利用Memcached在Master與Slave之間做過渡:
此處輸入圖片的描述

  • 用戶U購買電子書B:memcached->add('U:B',true);
  • 主數據庫: insert into Master (U,B);
  • 用戶U觀看電子書B: select 購買記錄 [user='U',book='B'] from Slave;
    如果沒查詢到,則memcached->get('U:B'),查到則說明已購買但有主從延遲.
  • 如果Memcached中也沒查詢到,用戶無權觀看該書.

分佈式緩存

Memcached雖然名義上是分佈式緩存,但其自身並未實現分佈式算法.當一個請求到達時,需要由客戶端實現的分佈式算法將不同的key路由到不同的Memcached服務器中.而分佈式取模算法有着致命的缺陷(詳細可參考分佈式之取模算法的缺陷), 因此Memcached客戶端一般採用一致性Hash算法來保證分佈式.

  • 目標:
    • key的分佈儘量均勻;
    • 增/減服務器節點對於其他節點的影響儘量小.

一致性Hash算法

  • 首先開闢一塊非常大的空間(如圖中:0~232),然後將所有的數據使用hash函數(如MD5、Ketama等)映射到這個空間內,形成一個Hash環. 當有數據需要存儲時,先得到一個hash值對應到hash環上的具體位置(如k1),然後沿順時針方向找到一臺機器(如B),將k1存儲到B這個節點中:

  • 如果B節點宕機,則B上的所有負載就會落到C節點上:
    此處輸入圖片的描述

  • 這樣,只會影響C節點,對其他的節點如A、D的數據都不會造成影響. 然而,這樣又會帶來一定的風險,由於B節點的負載全部由C節點承擔,C節點的負載會變得很高,因此C節點又會很容易宕機,依次下去會造成整個集羣的不穩定.
    理想的情況下是當B節點宕機時,將原先B節點上的負載平均的分擔到其他的各個節點上. 爲此,又引入了“虛擬節點”的概念: 想象在這個環上有很多“虛擬節點”,數據的存儲是沿着環的順時針方向找一個虛擬節點,每個虛擬節點都會關聯到一個真實節點,但一個真實節點會對應多個虛擬節點,且不同真實節點的多個虛擬節點是交差分佈的:
    此處輸入圖片的描述
    圖中A1、A2、B1、B2、C1、C2、D1、D2 都是“虛擬節點”,機器A負責存儲A1、A2的數據, 機器B負責存儲B1、B2的數據… 只要虛擬節點數量足夠多分佈均勻,當其中一臺機器宕機之後,原先機器上的負載就會平均分配到其他所有機器上(如圖中節點B宕機,其負載會分擔到節點A和節點D上).


Java實現

/**
 * @author jifang.
 * @since 2016/6/5 11:55.
 */
public class ConsistentHash<Node> {

    /**
     * 虛擬節點-真實節點Map
     */
    public SortedMap<Long, Node> VRNodesMap = new TreeMap<>();

    /**
     * 虛擬節點數目
     */
    private int vCount = 50;

    /**
     * 真實節點數目
     */
    private int rCount = 0;

    public ConsistentHash() {
    }

    public ConsistentHash(int vCount) {
        this.vCount = vCount;
    }

    public ConsistentHash(List<Node> rNodes) {
        init(rNodes);
    }

    public ConsistentHash(List<Node> rNodes, int vCount) {
        this.vCount = vCount;
        init(rNodes);
    }

    private void init(List<Node> rNodes) {
        if (rNodes != null) {
            for (Node node : rNodes) {
                add(rCount, node);
                ++rCount;
            }
        }
    }

    public void addRNode(Node rNode) {
        add(rCount, rNode);
        ++rCount;
    }

    public void rmRNode(Node rNode) {
        --rCount;
        remove(rCount, rNode);
    }

    public Node getRNode(String key) {
        // 沿環的順時針找到一個虛擬節點
        SortedMap<Long, Node> tailMap = VRNodesMap.tailMap(hash(key));
        if (tailMap.size() == 0) {
            return VRNodesMap.get(VRNodesMap.firstKey());
        }
        return tailMap.get(tailMap.firstKey());
    }

    private void add(int rIndex, Node rNode) {
        for (int j = 0; j < vCount; ++j) {
            VRNodesMap.put(hash(String.format("RNode-%s-VNode-%s", rIndex, j)), rNode);
        }
    }

    private void remove(int rIndex, Node rNode) {
        for (int j = 0; j < vCount; ++j) {
            VRNodesMap.remove(hash(String.format("RNode-%s-VNode-%s", rIndex, j)));
        }
    }

    /**
     * MurMurHash算法,是非加密HASH算法,性能很高,
     * 比傳統的CRC32,MD5,SHA-1(這兩個算法都是加密HASH算法,複雜度本身就很高,帶來的性能上的損害也不可避免)
     * 等HASH算法要快很多,而且據說這個算法的碰撞率很低.
     * http://murmurhash.googlepages.com/
     */
    private Long hash(String key) {

        ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
        int seed = 0x1234ABCD;

        ByteOrder byteOrder = buf.order();
        buf.order(ByteOrder.LITTLE_ENDIAN);

        long m = 0xc6a4a7935bd1e995L;
        int r = 47;

        long h = seed ^ (buf.remaining() * m);

        long k;
        while (buf.remaining() >= 8) {
            k = buf.getLong();

            k *= m;
            k ^= k >>> r;
            k *= m;

            h ^= k;
            h *= m;
        }

        if (buf.remaining() > 0) {
            ByteBuffer finish = ByteBuffer.allocate(8).order(
                    ByteOrder.LITTLE_ENDIAN);
            // for big-endian version, do this first:
            // finish.position(8-buf.remaining());
            finish.put(buf).rewind();
            h ^= finish.getLong();
            h *= m;
        }

        h ^= h >>> r;
        h *= m;
        h ^= h >>> r;

        buf.order(byteOrder);
        return h;
    }
}
  • 測試
public class ConsistentHashMain {

    private static final int KEY_COUNT = 1000;

    @Test
    public void test() {
        ConsistentHash<String> nodes = new ConsistentHash<>(new ArrayList<String>(), 50);
        nodes.addRNode("10.45.156.11");
        nodes.addRNode("10.45.156.12");
        nodes.addRNode("10.45.156.13");
        nodes.addRNode("10.45.156.14");
        nodes.addRNode("10.45.156.15");
        nodes.addRNode("10.45.156.16");
        nodes.addRNode("10.45.156.17");
        nodes.addRNode("10.45.156.18");
        nodes.addRNode("10.45.156.19");
        nodes.addRNode("10.45.156.10");

        Map<String, String> map = new HashMap<>();
        initMap(map, nodes);

        // 刪除節點
        nodes.rmRNode("10.45.156.19");

        // 增加節點
        nodes.addRNode("10.45.156.20");

        int mis = 0;
        for (Map.Entry<String, String> entry : map.entrySet()) {
            String key = entry.getKey();
            String value = entry.getValue();
            if (!nodes.getRNode(key).equals(value)) {
                ++mis;
            }
        }

        System.out.println(String.format("當前命中率爲:%s%%", (KEY_COUNT - mis) * 100.0 / KEY_COUNT));
    }

    private void initMap(Map<String, String> map, ConsistentHash<String> nodes) {
        for (int i = 0; i < KEY_COUNT; ++i) {
            String key = String.format("key-%s", i);
            map.put(key, nodes.getRNode(key));
        }
    }
}

經過實際測試: 當有十臺真實節點,而每個真實節點有50個虛擬節點時,在發生一臺實際節點宕機/新增一臺節點的情況時,命中率仍然能夠達到90%左右.對比簡單取模Hash算法:
此處輸入圖片的描述
當節點從N到N-1時,緩存的命中率直線下降爲1/N(N越大,命中率越低);一致性Hash的表現就優秀多了:
此處輸入圖片的描述
命中率只下降爲原先的 (N-1)/N ,且服務器節點越多,性能越好.因此一致性Hash算法可以最大限度地減小服務器增減時的緩存重新分佈帶來的壓力.


XMemcached實現

實際上XMemcached客戶端自身實現了很多一致性Hash算法(KetamaMemcachedSessionLocator/PHPMemcacheSessionLocator), 因此在開發中沒有必要自己去實現:
此處輸入圖片的描述

  • 示例: 支持分佈式的MemcachedFilter:
/**
 * @author jifang.
 * @since 2016/5/21 15:50.
 */
public class MemcachedFilter implements Filter {

    private MemcachedClient memcached;

    private static final int _1MIN = 60;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        try {
            MemcachedClientBuilder builder = new XMemcachedClientBuilder(
                    AddrUtil.getAddresses("10.45.156.11:11211" +
                            "10.45.156.12:11211" +
                            "10.45.156.13:11211"));
            builder.setSessionLocator(new KetamaMemcachedSessionLocator());
            memcached = builder.build();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 對PrintWriter包裝
        MemcachedWriter mWriter = new MemcachedWriter(response.getWriter());
        chain.doFilter(req, new MemcachedResponse((HttpServletResponse) response, mWriter));

        HttpServletRequest request = (HttpServletRequest) req;
        String key = request.getRequestURI();

        Enumeration<String> names = request.getParameterNames();
        if (names.hasMoreElements()) {
            String name = names.nextElement();
            StringBuilder sb = new StringBuilder(key)
                    .append("?").append(name).append("=").append(request.getParameter(name));
            while (names.hasMoreElements()) {
                name = names.nextElement();
                sb.append("&").append(name).append("=").append(request.getParameter(name));
            }
            key = sb.toString();
        }

        try {
            String rspContent = mWriter.getRspContent();
            memcached.set(key, _1MIN, rspContent);
        } catch (TimeoutException | InterruptedException | MemcachedException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void destroy() {
    }


    private static class MemcachedWriter extends PrintWriter {

        private StringBuilder sb = new StringBuilder();

        private PrintWriter writer;

        public MemcachedWriter(PrintWriter out) {
            super(out);
            this.writer = out;
        }

        @Override
        public void print(String s) {
            sb.append(s);
            this.writer.print(s);
        }

        public String getRspContent() {
            return sb.toString();
        }
    }

    private static class MemcachedResponse extends HttpServletResponseWrapper {

        private PrintWriter writer;

        public MemcachedResponse(HttpServletResponse response, PrintWriter writer) {
            super(response);
            this.writer = writer;
        }

        @Override
        public PrintWriter getWriter() throws IOException {
            return this.writer;
        }
    }
}

以上代碼最好有Nginx的如下配置支持:

Nginx以前端請求的"URI+Args"作爲key去請求Memcached,如果key命中,則直接由Nginx從緩存中取出數據響應前端;未命中,則產生404異常,Nginx捕獲之並將request提交後端服務器.在後端服務器中,request被MemcachedFilter攔截, 待業務邏輯執行完, 該Filter會將Response的數據拿到並寫入Memcached, 以備下次直接響應.


參考:
緩存系統MemCached的Java客戶端優化歷程
memcached Java客戶端spymemcached的一致性Hash算法
一致性哈希算法及其在分佈式系統中的應用
陌生但默默一統江湖的MurmurHash
Hash 函數概覽

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