在 Java 中利用 redis 實現分散式全域性唯一標識服務

楊高超發表於2018-01-02

獲取全域性唯一標識的方法介紹

在一個IT系統中,獲取一個物件的唯一識別符號是一個普遍的需求。在以前的單體應用中,如果資料庫是一個單資料庫的結構。通常可以利用資料庫的自增欄位來獲取這個唯一標識。例如,在 Mysql 資料庫中,我們可以通過 sql 語句建立一個自增長的 int 欄位型別的表。如下所示。

CREATE TABLE student
(
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(16),
    PRIMARY KEY (id)
)
複製程式碼

然後插入兩條資料

INSERT INTO student(name) VALUE('yanggch');
INSERT INTO student(name) VALUE('grace');
複製程式碼

通過 SQL 語句檢視錶資料

 SELECT * FROM student;
複製程式碼

得到如下的結果

資料庫查詢結果

可以看到,雖然我們在通過 SQL 插入資料的時候沒有指定 id 欄位的值,但是因為該欄位的 AUTO_INCREMENT 自增長的特性,自動的給兩條記錄新增了1和2兩個值。

這個方法有兩個主要問題。一個是如果是一個分庫分表的資料庫結構,那麼在分佈在不同例項中的同一個表中的id是重複的。另一個問題是記錄插入到資料庫裡後,我們在程式碼中並不能知道剛剛插入資料庫的記錄的主鍵的值到底是什麼。如果我們的一個業務是要同時插入一條主表記錄一節一系列以這條主表記錄主鍵為外來鍵的子表記錄,我們在插入子表記錄的時候,不知道對應的外來鍵的值是多少。導致無法插入。例如如果我們有一個下單業務,要求在訂單表中插入一條訂單記錄,同時在訂單明細中插入多條在這個訂單中購買的商品的詳細資訊的記錄。訂單資料插入成功後,我們不知道訂單的主鍵的值,所以我們也就無法正確的插入商品詳細資訊記錄了。

另外一個利用資料庫自增欄位屬性獲取唯一標識方式是在資料庫中建立一個帶一個自增欄位的資料表。每次在表中插入一條記錄,然後將這條記錄的值取出來作為主鍵值。這個的問題是每次要另外在資料表中插入一條記錄,同時在多使用者使用的環境下,要嚴格保證你取到的記錄就是你插入的記錄。否則會導致主鍵重複。著會讓獲取唯一識別符號的速度變得比較慢。同時,這個方式在分庫分表的結構下,也不能讓唯一標識在全域性唯一。

還有一些其他的方式。例如用 uuid 演算法可以保證全域性唯一,也能保證高效能。但是他生成是一個字串,不能保證順序性,同時也太長了。

所以在分散式架構中,我們就需要一個滿足如下條件的唯一識別符號服務

  1. 全域性唯一
  2. 高效能
  3. 具備順序性
  4. 可以附加其他業務屬性

這裡我們可以用 redis 的 INCR 命令來作為生成全域性的唯一識別符號。INCR 命令的語法是

INCR key
複製程式碼

根據 redis 的官網的 INCR 命令介紹,它是一個原子操作,效果是是將 redis 資料庫中 key 的值加一併且返回這個結果。如果 key 不存在,將在執行加一操作前,將這個 key 的值設定為0,也就是說執行這個命令的結果是從 1 開始一直累加下去的。

同時我們可以看到這個命令的演算法時間複雜度是 O(1),而 redis 的資料是儲存在記憶體中的,這個命令的執行速度是非常快的。在 redis 伺服器為雙核 16g環境下,通過千兆區域網在另一臺伺服器上命令列執行壓力測試

redis-benchmark -h 10.110.2.56 -p 52981 -a hhSbcpotThgWdnxJNhrzwstSP20DvYOldkjf
複製程式碼

結果如下

redis INCR 壓力測試結果
可以看到每秒可以生成5萬個標識。這個可以滿足一般的高效能需求了


通過 Java 和 redis 實現一個全域性唯一標識服務

接下來我們來用繼續來在 Java 中利用 redis 來實現一個全域性唯一標識的服務。這個服務要滿足如下的需求

  1. 全域性唯一
  2. 高效能
  3. 具備順序性
  4. 可以將日期數字作為全域性唯一標識的字首
  5. 可以每天從 1 開始重新計數
  6. 不同的實體型別可以單獨生成標識。例如訂單標識,會員標識
  7. 可以在新的一天中從 1 開始計數

定義唯一標識服務介面

package com.x9710.common.redis;

/**
 * 全域性唯一標識服務介面
 *
 * @author 楊高超
 * @since 2017-12-10
 */
public interface UUIDService {

/**
 * 每天從 1 開始生成唯一標識
 *
 * @param key     要生成唯一標識的物件
 * @param length  要生成為唯一標識字尾的長度。不包括需要附加的時間字首
 *                如果 haveDay = false 或者 length 長度小於標識字尾的長度則無效
 * @param haveDay 是否要附加日期字首
 * @return 唯一標識
 * @throws Exception 異常
 */
Long fetchDailyUUID(String key, Integer length, Boolean haveDay) throws Exception;

/**
 * 全域性從 1 開始生成唯一標識
 *
 * @param key     要生成唯一標識的物件
 * @param length  要生成為唯一標識字尾的長度。不包括需要附加的時間字首
 *                如果 haveDay = false 或者 length 長度小於標識字尾的長度則無效
 * @param haveDay 是否要附加日期字首。
 * @return 唯一標識
 * @throws Exception 異常
 */
Long fetchUUID(String key, Integer length, Boolean haveDay) throws Exception;
}
複製程式碼

基於 redis 實現唯一標識服務

package com.x9710.common.redis.impl;

import com.x9710.common.redis.RedisConnection;
import com.x9710.common.redis.UUIDService;
import redis.clients.jedis.Jedis;

import java.text.DateFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;

/**
 * @author 楊高超
 * @since 2017-11-19
 */
public class UUIDServiceRedisImpl implements UUIDService {
private RedisConnection redisConnection;
private Integer dbIndex;

private DateFormat df = new SimpleDateFormat("yyyyMMdd");

public void setRedisConnection(RedisConnection redisConnection) {
    this.redisConnection = redisConnection;
}

public void setDbIndex(Integer dbIndex) {
    this.dbIndex = dbIndex;
}

public Long fetchDailyUUID(String key, Integer length, Boolean haveDay) {
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        Calendar now = new GregorianCalendar();
        String day = df.format(now.getTime());
        //新的一天,通過新 key 獲取值,每天都能從1開始獲取
        key = key + "_" + day;
        Long num = jedis.incr(key);
        //設定 key 過期時間
        if (num == 1) {
            jedis.expire(key, (24 - now.get(Calendar.HOUR_OF_DAY)) * 3600 + 1800);
        }
        if (haveDay) {
            return createUUID(num, day, length);
        } else {
            return num;
        }
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
}

public Long fetchUUID(String key, Integer length, Boolean haveDay) {
    Jedis jedis = null;
    try {
        jedis = redisConnection.getJedis();
        jedis.select(dbIndex);
        Calendar now = new GregorianCalendar();
        Long num = jedis.incr(key);
        
        if (haveDay) {
            String day = df.format(now.getTime());
            return createUUID(num, day, length);
        } else {
            return num;
        }
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }
}

private Long createUUID(Long num, String day, Integer length) {
    String id = String.valueOf(num);
    if (id.length() < length) {
        NumberFormat nf = NumberFormat.getInstance();
        nf.setGroupingUsed(false);
        nf.setMaximumIntegerDigits(length);
        nf.setMinimumIntegerDigits(length);
        id = nf.format(num);
    }
    return Long.parseLong(day + id);
}
}
複製程式碼

編寫測試用例

在 Junit4 中不支援多執行緒測試,所以這裡直接採用了 main 方法中執行測試用例。

package com.x9710.common.redis.test;

import com.x9710.common.redis.RedisConnection;
import com.x9710.common.redis.impl.UUIDServiceRedisImpl;

import java.util.Date;

public class RedisUUIDTest {

public static void main(String[] args) {
    for (int i = 0; i < 20; i++) {
        new Thread(new Runnable() {
            public void run() {
                RedisConnection redisConnection = RedisConnectionUtil.create();
                UUIDServiceRedisImpl uuidServiceRedis = new UUIDServiceRedisImpl();
                uuidServiceRedis.setRedisConnection(redisConnection);
                uuidServiceRedis.setDbIndex(15);
                try {
                    for (int i = 0; i < 100; i++) {
                        System.out.println(new Date() + " get uuid = " + 
                              uuidServiceRedis.fetchUUID("MEMBER", 8, Boolean.TRUE) + 
                              " by globle in " + Thread.currentThread().getName());
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                RedisConnection redisConnection = RedisConnectionUtil.create();
                UUIDServiceRedisImpl uuidServiceRedis = new UUIDServiceRedisImpl();
                uuidServiceRedis.setRedisConnection(redisConnection);
                uuidServiceRedis.setDbIndex(15);
                try {
                    for (int i = 0; i < 100; i++) {
                        System.out.println(new Date() + " get uuid = " + 
                            uuidServiceRedis.fetchDailyUUID("ORDER", 8, Boolean.TRUE) + 
                            " by daily in " + Thread.currentThread().getName());
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}
}
複製程式碼

執行結果如下

Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000003 by member in Thread-32
Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000001 by member in Thread-8
Mon Dec 11 16:14:10 CST 2017 get uuid = 2017121100000007 by order in Thread-19
......
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100002000 by member in Thread-14
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100001999 by member in Thread-16
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100001999 by order in Thread-39
Mon Dec 11 16:14:14 CST 2017 get uuid = 2017121100002000 by order in Thread-39
複製程式碼

這樣,我們就實現了一個滿足開始七個需求的一個基本的唯一標識服務。只要呼叫這個模組的程式連線的 redis 伺服器的配置一樣,就能實現在同一個物件高效生成唯一標識的基礎服務。你還可以將這個包裝成為一個 rest 服務,客戶端不需要直接連線 redis 伺服器,直接通過 rest 的http 服務遠端獲取唯一標識即可。

這個程式實在前一篇文章《為什麼要用快取伺服器以及在 Java 中實現一個 redis 快取服務》的基礎上新增新的實現類的方式完成的。程式碼同步釋出在 GitHub 倉庫

原文發表在簡書中,原始連結

相關文章