主鍵生成器效率提高方案|基於雪花算法和Redis控制進程隔離

背景

  1. 主鍵生成效率用數據庫自增效率也是比較高的,爲何要用主鍵生成器呢?是由於須要insert主表和明細表時,明細表有個字段是主表的主鍵做爲關聯。因此就須要先生成主鍵填好主代表細表的信息後再一次過在一個事務內插入。或者是產生支付流水號時要全局惟一,因此要先生成後插入,不能靠數據庫主鍵。
  2. 網上有不少主鍵生成器方式,其中有算法部分和實現部分。算法部分通常就是雪花算法或者以業務編號前綴+年月日形式。
  3. 通常算法設計沒有問題,而在實現方案上,只是同窗利用Redis不少實現起來的都是不高效的,他們沒考慮Redis都是單線程的狀況下多個同時請求生成會有等待的時間。下面咱們來對比2款實現方式,看看他們的問題點在哪裏,還有個人改進實現方案。

點贊再看,關注公衆號:【地藏思惟】給你們分享互聯網場景設計與架構設計方案 掘金:地藏Kelvin https://juejin.im/user/5d67da8d6fb9a06aff5e85f7java

目的

  1. 減小網絡鏈接Redis的次數,來減小TCP次數。
  2. 減小因Redis的單線程串行形成的等待
  3. 兩個進程、docker或者說兩個服務器之間隔離
  4. 減小Redis內存使用率

最終實現工具

  1. Redis: incr、get、set以達到控制進程隔離
  2. 只鏈接一次Redis
  3. LUA腳本保持原子
  4. syncronize
  5. 雪花算法以達到不重複鍵

算法場景

算法場景一:雪花算法

生成出來的主鍵使用了long類型,long類型爲8字節工64位。可表示的最大值位2^64-1(18446744073709551615,裝換成十進制共20位的長度,這個是無符號的長整型的最大值)。linux

單常見使用的是long 不是usign long因此最大值爲2^63-1(9223372036854775807,裝換成十進制共19的長度,這個是long的長整型的最大值)git

  1. 時間戳這個很容易得,搞個Date轉換成Timestamp就行了。
  2. 數據中心這個字段,能夠人爲讀環境變量填寫,畢竟linux服務器不知道你把這個機器放在哪一個中心(這個中心是指異地多活的時候說的那個中心)。
  3. 機器識別號(就是你係統所在的服務器電腦)如何保持惟一是本章內容一個問題。
  4. 序列編號若是要用自增形式,用那種實現會比較高效率呢?不少阻塞不高效的問題出如今這個地方。

算法場景二:按業務要求規則生成(多數用於流水號,如支付流水)

這種由於long值太大,因此拼接後,會做爲String形式。如支付寶的流水號redis

業務號 毫秒 自增
000 0000 00 00 00 00 00 000 000

一樣序列編號若是要用自增形式,用那種實現會比較高效率呢? 是否須要加上數據中心、機器識別號來隔離進程呢?算法

然而上面的都是算法,只是肯定了這個主鍵的組成部分,可是由於數據中心位和機器識別位沒有肯定,就仍是須要看如何實現出進程隔離,自增序列如何作才高效。docker

實現方案

方案一

網上有一種作法,生成時間戳、自增部分的邏輯都在redis裏面作,先提早寫好LUA腳本,而後經過Redis框架eval執行腳本。數據庫

腳本內作如下Redis內容:服務器

  1. 生成時間
  2. 以時間做爲redis的key自增(如主鍵須要數據中心位和系統所在服務器編號可拼在key上),這個步驟獲得自增序列。爲了保持一毫秒內不重複。
  3. 設置超時時間。

這種方式實際上是有問題的,假設A系統,有4個負載均衡節點,同一個時候,每一個節點有10萬個請求生成主鍵。下一秒也有10萬個請求生成主鍵。網絡

由於Redis是單線程處理每一個命令,因此是串行的。架構

不管你用上述算法方案兩種的哪種,那如今就有40萬個生成主鍵的網絡tcp請求打到redis,一個是網絡TCP數量比較多,產生屢次握手,另一個是串行問題致使系統一直在等結果,因此就會有效率問題

萬一第一秒的40萬個都沒作完,第二秒的40萬個都在等待了。況且有10個系統都連這個redis呢?redis內存夠用嗎?

方案二

自增部分都在redis裏面作。

  1. 服務生成時間戳
  2. 獲取數據中心識別號、機器識別號(若是是雪花算法)
  3. 獲取業務編碼(若是是按業務生成)
  4. 到redis生成同一毫秒內的自增序列

這樣的方案內心是在想,把精確到毫秒後,以自增序列以前的那一排數字做爲key,請求到redis,incr就行了,這樣就能夠不一樣毫秒之間就能夠同時incr了吧?

這個方案其實也是有一樣上述方案一問題的,多機器同時訪問一個redis。

雖然redis要作的命令變少了,可是由於redis是單線程的,可能第一秒內有10萬個incr進redis,致使第二秒10萬個進來incr的時候,也是因爲redis的單線程而等待着的。既然這樣也是等待,還不如直接在系統裏面syncronize內存的等待還少一次redis網絡鏈接呢。

討論

  1. 解決這個問題有人說,可不可使用合併請求,而後用redis的pipeline一次過丟一堆命令到redis,這樣就能夠減小tcp鏈接的次數了。 然而即便只有一個tcp請求,可是也是有不少個命令要一個redis去處理,只是減小tcp而已,用請求合併還要搞個定時任務去作呢,這個定時任務的間隔時間還要特別短,很是影響CPU。

  2. 也會有說,那就加多幾個redis吧。讓系統訪問redis時,帶上一個redis識別號。這樣就能達到多個線程處理了。 如:有三臺redis,分別請求三個redis時,都帶上一個號叫分片號,代表是哪臺redis生成的。

redis機器 自增序列帶上下面的數字做爲前綴
A 0
B 1
C 2

這樣也不是不能夠,只是說仍是每次訪問Redis有網絡鏈接的消耗和redis單線程處理讓系統等待。

方案三:改進實現,只訪問一次Redis肯定雪花算法中的機器識別號,而後系統各自生成。

實現概述

這個方案是基於內存實現,沒有爲了統一自增序列而每次網絡鏈接訪問redis,也不用負載均衡4個節點而致使的4個節點的redis命令都丟給一個redis。只須要系統的4個負載均衡節點本身內部完成,這樣就把redis單線程的缺點改成4個進程本身完成,想要增長效率,只須要增長機器就能夠了,不用屢次依賴中間件。

時間戳 數據中心號 機器識別號 自增序列
41個位 5個位 5個位 12個位
  1. 時間戳由系統生成沒有疑問

  2. 數據中心號從環境變量裏面讀取

  3. 爲了避免同的進程,意思也是爲了相同的系統,可是不一樣的負載均衡節點之間相互隔離,保證每一個負載節點生成的雪花算法結果都是不同的。因此必須帶上機器識別號,即便是使用按業務規則生成的算法方案,也是須要添加機器識別號的。獲取識別後就能夠保存在靜態變量,並初始化雪花算法實例。(訪問Redis的就只有這一步,在系統啓動的時候完成,後續不用再訪問redis

  4. 肯定第三步後,按照雪花算法生成主鍵的邏輯,都在java系統裏面作。自增序列在java系統裏實現,不經過redis。

因此咱們對系統啓動時的代碼須要生成主鍵時的代碼分開來看。

主要關鍵點

在於機器識別號生成必須不相同,因此生成機器識別號的邏輯是在redis,而redis部分必需要用LUA腳本實現,保持原子性。

代碼流程

1~11 步是系統啓動的時候作的。

12~16 步是在系統跑起來後,要生成主鍵的時候觸發的。

如何保持不一樣的進程之間隔離,在第5到第10步,請留意。

系統啓動時

  1. 你的系統啓動完後觸發

  2. java句柄讀取本機的IP地址

  3. 在LUA腳本中調用 redis.call('GET',dmkey); 以數據中心+IP地址做爲Key來GET,獲取一個數字做爲機器識別號。若是能獲取到,就證實不是第一次訪問,就能夠返回給系統

  4. 若是獲取不到,以一個固定字符串「_idgenerator」做爲key,觸發incr

  5. 因原子性,因此incr得一個數字,用這個來做爲本次線程訪問redis得出的machineId機器識別號

  6. 而後以數據中心號和ip地址 拼接後做爲key,調用redis的set key, value爲剛剛的machineId。 這樣就可讓相同的數據中心,而且相同的IP地址在下次直接get到機器識別號。

  7. 這個號碼保存在靜態成員變量裏面,這樣就不用每次生成主鍵的時候都須要去訪問,由於數據中心+IP地址是恆定的。 (注意這幾步必須在LUA裏面實現,若是在java代碼裏面實現,頗有可能會incr出來的號,在set key那一步被其餘機器覆蓋了)

截止到流程圖的11步結束:這些redis的邏輯都只須要在服務啓動的時候觸發一次就行了(這裏完成目的的第一點減小網絡鏈接)。由於觸發一次後就能夠保存在代碼靜態變量裏面。 根據ip來肯定出機器識別號後,這樣生成主鍵的過程都是保持進程間隔離的。完成目的的第三點,數據隔離。並且利用redis保證了原子性,機器識別號不會重複。

代碼貼不全,你們明白思路就好,具體實如今下方Gitee。

private String LUA_SCRIPT = "/redis-script.lua";

    List<Object> keys = new ArrayList<>(2);

    private static final AtomicReference<String> ENQUEUE_LUA_SHA = new AtomicReference<>();

    @Override
    public void afterPropertiesSet() throws Exception {
        // TODO 讀取properties配置得到datacenterId
        Long datacenterId = 0L;
        // 本方法內執行命令得到ip做爲
        String ip = InetAddress.getLocalHost().getHostAddress();

        String luaContent = null;
        ClassPathResource resource = new ClassPathResource(LUA_SCRIPT);
        try {
            luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            e.printStackTrace();
        }
        String sha = redissonClient.getScript().scriptLoad(luaContent);
        ENQUEUE_LUA_SHA.compareAndSet(null, sha);
        keys.add(datacenterId);
        keys.add(ip);
        // 經過LUA腳本得到機器識別號
        Long machineId = (Long) this.redissonClient.getScript().eval(Mode.READ_WRITE, ENQUEUE_LUA_SHA.get(),
                ReturnType.INTEGER, keys, null);
        // 保存機器識別號並初始化雪花算法實例(後續再看裏面邏輯,只須要知道是保存machineId就行)
        Snowflake.getInstance(machineId, datacenterId);
    }
-- need redis 3.2+
local prefix = '__idgenerator_';
local datacenterId = KEYS[1];
local ip = KEYS[2];

if datacenterId == nil then
	datacenterId = 0
end
if ip == nil then
	return -1
end


local dmkey= prefix ..'_' .. datacenterId ..'_' .. ip;

local machineId ;
machineId = redis.call('GET',dmkey);
if machineId!=nil then
	return machineId
end
 
machineId = tonumber(redis.call('INCRBY', prefix, 1));
if machineId > 0 then
	redis.call('SET',dmkey,machineId);
	return machineId;
end

當須要生成主鍵時

  1. 開啓syncronize
  2. 根據雪花算法或者按業務規則算法生成時間戳
  3. 若是你有異地多活就要在環境變量讀取數據中心id
  4. 主要是讀取系統啓動後獲取到的machineId
  5. 依賴syncronize,自增序列+1。

代碼貼不全,你們明白思路就好,具體實如今下方Gitee。

public synchronized long nextId() {
        long timestamp = timeGen();
        // 獲取當前毫秒數
        // 若是服務器時間有問題(時鐘後退) 報錯。
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format(
                    "Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }
        // 若是上次生成時間和當前時間相同,在同一毫秒內
        if (lastTimestamp == timestamp) {
            // sequence自增,由於sequence只有12bit,因此和sequenceMask相與一下,去掉高位
            sequence = (sequence + 1) & sequenceMask;
            // 判斷是否溢出,也就是每毫秒內超過4095,當爲4096時,與sequenceMask相與,sequence就等於0
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
                // 自旋等待到下一毫秒
            }
        } else {
            sequence = 0L;
            // 若是和上次生成時間不一樣,重置sequence,就是下一毫秒開始,sequence計數從新從0開始累加
        }
        lastTimestamp = timestamp;
        // 最後按照規則拼出ID。
        // 000000000000000000000000000000000000000000 00000 00000 000000000000
        // time datacenterId workerId sequence
        // return ((timestamp - twepoch) << timestampLeftShift) | (datacenterId <<
        // datacenterIdShift)
        // | (workerId << workerIdShift) | sequence;

        // 由於雪花算法沒那麼多位置給workerId 若是要改,那就要改雪花算法數據中心id和workerId的佔位位置
        long longStr = ((timestamp - twepoch) << timestampLeftShift) | (datacenterId << datacenterIdShift)
                | (workerId << workerIdShift) | sequence;
        // System.out.println(longStr);
        return longStr;
    }


}

在這一步實現自增長一,內存態完成,無須依賴redis。

這裏完成目的的第二點,再也不須要依賴redis的單線程來作等待。改成由系統那麼多個負載均衡節點並行處理,由於反正在redis中incr都是內存態的也是串行的。

而且生成的主鍵變量都是局部變量,用完就銷燬,不須要存放於redis增長redis壓力。

完成目的4減小Redis內存使用率

可是要注意,由於machineId只能站5位,因此最大是31,若是到32了,就會變0了。由於雪花算法沒那麼多位置給machineId 若是要改,那就要改雪花算法數據中心id和machineId的佔位數量。

總結

到這裏,咱們基於雪花算法,用Redis作控制進程的隔離,只須要一次鏈接,保證不一樣的服務負載節點上生成的主鍵不一致,來減小網絡TCP鏈接的訪問。也利用了syncronize來保證自增序列不重複的方式,來減小Redis單線程處理的等待時間。

代碼樣例

代碼太多長,不貼那麼多了,看Gitee吧 https://gitee.com/kelvin-cai/IdGenerator


歡迎關注公衆號,文章更快一步

個人公衆號 :地藏思惟

掘金:地藏Kelvin

簡書:地藏Kelvin

個人Gitee: 地藏Kelvin https://gitee.com/kelvin-cai

相關文章
相關標籤/搜索