Java擴充套件Nginx之七:共用記憶體

2023-07-17 09:01:08

歡迎存取我的GitHub

這裡分類和彙總了欣宸的全部原創(含配套原始碼):https://github.com/zq2599/blog_demos

本篇概覽

  • 作為《Java擴充套件Nginx》系列的第七篇,咱們來了解一個實用工具共用記憶體,正式開始之前先來看一個問題
  • 在一臺電腦上,nginx開啟了多個worker,如下圖,如果此時我們用了nginx-clojure,就相當於有了四個jvm程序,彼此相互獨立,對於同一個url的多次請求,可能被那四個jvm中的任何一個處理:
  • 現在有個需求:統計某個url被存取的總次數,該怎麼做呢?在java記憶體中用全域性變數肯定不行,因為有四個jvm程序都在響應請求,你存到哪個上面都不行
  • 聰明的您應該想到了redis,確實,用redis可以解決此類問題,但如果不涉及多個伺服器,而只是單機的nginx,還可以考慮nginx-clojure提供的另一個簡單方案:共用記憶體,如下圖,一臺電腦上,不同程序操作同一塊記憶體區域,存取總數放入這個記憶體區域即可:
  • 相比redis,共用記憶體的好處也是顯而易見的:
  1. redis是額外部署的服務,共用記憶體不需要額外部署服務
  2. redis請求走網路,共用記憶體不用走網路
  • 所以,單機版nginx如果遇到多個worker的資料同步問題,可以考慮共用記憶體方案,這也是咱們今天實戰的主要內容:在使用nginx-clojure進行java開發時,用共用記憶體在多個worker之間同步資料

  • 本文由以下內容組成:

  1. 先在java記憶體中儲存計數,放在多worker環境中執行,驗證計數不準的問題確實存在
  2. 用nginx-clojure提供的Shared Map解決問題

用堆記憶體儲存計數

  • 寫一個content handler,程式碼如下,用UUID來表明worker身份,用requestCount記錄請求總數,每處理一次請求就加一:
package com.bolingcavalry.sharedmap;

import nginx.clojure.java.ArrayMap;
import nginx.clojure.java.NginxJavaRingHandler;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import static nginx.clojure.MiniConstants.CONTENT_TYPE;
import static nginx.clojure.MiniConstants.NGX_HTTP_OK;

public class HeapSaveCounter implements NginxJavaRingHandler {

    /**
     * 通過UUID來表明當前jvm程序的身份
     */
    private String tag = UUID.randomUUID().toString();

    private int requestCount = 1;

    @Override
    public Object[] invoke(Map<String, Object> map) throws IOException {

        String body = "From "
                    + tag
                    + ", total request count [ "
                    + requestCount++
                    + "]";

        return new Object[] {
                NGX_HTTP_OK, //http status 200
                ArrayMap.create(CONTENT_TYPE, "text/plain"), //headers map
                body
        };
    }
}
  • 修改nginx.conf的worker_processes設定,改為auto,則根據電腦CPU核數自動設定worker數量:
worker_processes  auto;
  • nginx增加一個location設定,服務類是剛才寫的HeapSaveCounter:
location /heapbasedcounter {
	content_handler_type 'java';
    content_handler_name 'com.bolingcavalry.sharedmap.HeapSaveCounter';
}
  • 編譯構建部署,再啟動nginx,先看jvm程序有幾個,如下可見,除了jps自身之外有8個jvm程序,等於電腦的CPU核數,和設定的worker_processes是符合的:
(base) willdeMBP:~ will$ jps
4944
4945
4946
4947
4948
4949
4950
4968 Jps
4943
  • 先用Safari瀏覽器存取/heapbasedcounter,第一次收到的響應如下圖,總數是1:

  • 重新整理頁面,UUID不變,總數變成2,這意味著兩次請求到了同一個worker的JVM上:

  • 改用Chrome瀏覽器,存取同樣的地址,如下圖,這次UUID變了,證明請求是另一個worker的jvm處理的,總數變成了1:

  • 至此,問題得到證明:多個worker的時候,用jvm的類的成員變數儲存的計數只是各worker的情況,不是整個nginx的總數

  • 接下來看如何用共用記憶體解決此類問題

關於共用記憶體

  • nginx-clojure提供的共用記憶體有兩種:Tiny Map和Hash Map,它們都是key&value型別的儲存,鍵和值均可以是這四種型別:int,long,String, byte array
  • Tiny Map和Hash Map的區別,用下表來對比展示,可見主要是量化的限制以及使用記憶體的多少:
特性 Tiny Map Hash Map
鍵數量 2^31=2.14Billions 64位元系統:2^63
32位元系統:2^31
使用記憶體上限 64位元系統:4G
32位元系統:2G
受限於作業系統
單個鍵的大小 16M 受限於作業系統
單個值的大小 64位元系統:4G
32位元系統:2G
受限於作業系統
entry物件自身所用記憶體 24 byte 64位元系統:40 byte
32位元系統:28 byte
  • 您可以基於上述區別來選自使用Tiny Map和Hash Map,就本文的實戰而言,使用Tiny Map就夠用了
  • 接下來進入實戰

使用共用記憶體

  • 使用共用記憶體一共分為兩步,如下圖,先設定再使用:
  • 現在nginx.conf中增加一個http設定項shared_map,指定了共用記憶體的名稱是uri_access_counters
# 增加一個共用記憶體的初始化分配,型別tiny,空間1M,鍵數量8K
shared_map uri_access_counters  tinymap?space=1m&entries=8096;
  • 然後寫一個新的content handler,該handler在收到請求時,會在共用記憶體中更新請求次數,總的程式碼如下,有幾處要重點注意的地方,稍後會提到:
package com.bolingcavalry.sharedmap;

import nginx.clojure.java.ArrayMap;
import nginx.clojure.java.NginxJavaRingHandler;
import nginx.clojure.util.NginxSharedHashMap;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import static nginx.clojure.MiniConstants.CONTENT_TYPE;
import static nginx.clojure.MiniConstants.NGX_HTTP_OK;

public class SharedMapSaveCounter implements NginxJavaRingHandler {

    /**
     * 通過UUID來表明當前jvm程序的身份
     */
    private String tag = UUID.randomUUID().toString();

    private NginxSharedHashMap smap = NginxSharedHashMap.build("uri_access_counters");

    @Override
    public Object[] invoke(Map<String, Object> map) throws IOException {
        String uri = (String)map.get("uri");

        // 嘗試在共用記憶體中新建key,並將其值初始化為1,
        // 如果初始化成功,返回值就是0,
        // 如果返回值不是0,表示共用記憶體中該key已經存在
        int rlt = smap.putIntIfAbsent(uri, 1);

        // 如果rlt不等於0,表示這個key在呼叫putIntIfAbsent之前已經在共用記憶體中存在了,
        // 此時要做的就是加一,
        // 如果relt等於0,就把rlt改成1,表示存取總數已經等於1了
        if (0==rlt) {
            rlt++;
        } else {
            // 原子性加一,這樣並行的時候也會順序執行
            rlt = smap.atomicAddInt(uri, 1);
            rlt++;
        }

        // 返回的body內容,要體現出JVM的身份,以及share map中的計數
        String body = "From "
                + tag
                + ", total request count [ "
                + rlt
                + "]";

        return new Object[] {
                NGX_HTTP_OK, //http status 200
                ArrayMap.create(CONTENT_TYPE, "text/plain"), //headers map
                body
        };
    }
}
  • 上述程式碼已經新增了詳細註釋,相信您一眼就看懂了,我這裡挑幾個重點說明一下:
  1. 寫上述程式碼時要牢一件事:這段程式碼可能執行在高並行場景,既同一時刻,不同程序不同執行緒都在執行這段程式碼
  2. NginxSharedHashMap類是ConcurrentMap的子類,所以是執行緒安全的,我們更多考慮應該注意跨程序讀寫時的同步問題,例如接下來要提到的第三和第四點,都是多個程序同時執行此段程式碼時要考慮的同步問題
  3. putIntIfAbsent和redis的setnx類似,可以當做跨程序的分散式鎖來使用,只有指定的key不存在的時候才會設定成功,此時返回0,如果返回值不等於0,表示共用記憶體中已經存在此key了
  4. atomicAddInt確保了原子性,多程序並行的時候,用此方法累加可以確保計算準確(如果我們自己寫程式碼,先讀取,再累加,再寫入,就會遇到並行的覆蓋問題)
  5. 關於那個atomicAddInt方法,咱們回憶一下java的AtomicInteger類,其incrementAndGet方法在多執行緒同時呼叫的場景,也能計算準確,那是因為裡面用了CAS來確保的,那麼nginx-clojure這裡呢?我很好奇的去探尋了一下該方法的實現,這是一段C程式碼,最後沒看到CAS有關的迴圈,只看到一段最簡單的累加,如下圖:
  6. 很明顯,上圖的程式碼,在多程序同時執行時,是會出現資料覆蓋的問題的,如此只有兩種可能性了,第一種:即便是多個worker存在,執行底層共用記憶體操作的程序也只有一個
  7. 第二種:欣宸的C語言水平不行,根本沒看懂JVM呼叫C的邏輯,自我感覺這種可能性很大:如果C語言水平可以,欣宸就用C去做nginx擴充套件了,沒必要來研究nginx-clojure呀!(如果您看懂了此段程式碼的呼叫邏輯,還望您指點欣宸一二,謝謝啦)
  • 編碼完成,在nginx.conf上設定一個location,用SharedMapSaveCounter作為content handler:
location /sharedmapbasedcounter {
    content_handler_type 'java';
 	content_handler_name 'com.bolingcavalry.sharedmap.SharedMapSaveCounter';
}
  • 編譯構建部署,重啟nginx
  • 先用Safari瀏覽器存取/sharedmapbasedcounter,第一次收到的響應如下圖,總數是1:
  • 重新整理頁面,UUID發生變化,證明這次請求到了另一個worker,總數也變成2,這意味著共用記憶體生效了,不同程序使用同一個變數來計算資料:
  • 改用Chrome瀏覽器,存取同樣的地址,如下圖,UUID再次變化,證明請求是第三個worker的jvm處理的,但是存取次數始終正確:
  • 實戰完成,前面的程式碼中只用了兩個API操作共用記憶體,學到的知識點有限,接下來做一些適當的延伸學習

一點延伸

  • 剛才曾提到NginxSharedHashMap是ConcurrentMap的子類,那些常用的put和get方法,在ConcurrentMap中是在操作當前程序的堆記憶體,如果NginxSharedHashMap直接使用父類別的這些方法,豈不是與共用記憶體無關了?
  • 帶著這個疑問,去看NginxSharedHashMap的原始碼,如下圖,真相大白:get、put這些常用方法,都被重寫了,紅框中的nget和nputNumber都是native方法,都是在操作共用記憶體:
  • 至此,nginx-clojure的共用記憶體學習完成,高並行場景下跨程序同步資料又多了個輕量級方案,至於用它還是用redis,相信聰明的您心中已有定論

原始碼下載

名稱 連結 備註
專案主頁 https://github.com/zq2599/blog_demos 該專案在GitHub上的主頁
git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該專案原始碼的倉庫地址,https協定
git倉庫地址(ssh) [email protected]:zq2599/blog_demos.git 該專案原始碼的倉庫地址,ssh協定

歡迎關注部落格園:程式設計師欣宸

學習路上,你不孤單,欣宸原創一路相伴...