架構設計 | 分佈式業務系統中,全局ID生成策略

本文源碼:GitHub·點這裡 || GitEE·點這裡

一、全局ID簡介

在實際的開發中,幾乎所有的業務場景產生的數據,都需要一個唯一ID作為核心標識,用來流程化管理。比如常見的:

  • 訂單:order-id,查訂單詳情,物流狀態等;
  • 支付:pay-id,支付狀態,基於ID事務管理;

如何生成唯一標識,在普通場景下,一般的方法就可以解決,例如:

import java.util.UUID;
public class UuidUtil {
    public static String getUUid() {
        UUID uuid = UUID.randomUUID();
        return String.valueOf(uuid).replace("-","");
    }
}

這個方法可以解決絕大部分唯一ID需求的場景業務,但是網上各種UUID重複場景的描述帖,說的好像該API不好用。

絮叨一句:說一個真實使用的業務場景,大概是半年近3000萬的數據流水,用的就是UUID的API,暫時未捕捉到ID重複的問題,僅供參考。

二、雪花算法

1、概念簡介

Twitter公司開源的分佈式ID生成算法策略,生成的ID遵循時間的順序。

  • 1為位標識,始終為0,不可用;
  • 41位時間截,存儲時間截的差值(當前時間截-開始時間截);
  • 10位的機器標識,10位的長度最多支持部署1024個節點;
  • 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒產生4096個ID序號;

SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高。

2、編碼實現

工具類中很多可以自定義的,比如起始時間,機器ID配置等。

/**
 * 雪花算法ID生成
 */
public class SnowIdWorkerUtil {
    // 開始時間截 (2020-01-02)
    private final long timeToCut = 1577894400000L;
    // 機器ID所佔的位數
    private final long workerIdBits = 2L;
    // 數據標識ID所佔的位數
    private final long dataCenterIdBits = 8L;
    // 支持的最大機器ID,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
    // 支持的最大數據標識ID,結果是31
    private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
    // 序列在ID中占的位數
    private final long sequenceBits = 12L;
    // 機器ID向左移12位
    private final long workerIdShift = sequenceBits;
    // 數據標識ID向左移17位(12+5)
    private final long dataCenterIdShift = sequenceBits + workerIdBits;
    // 時間截向左移22位(5+5+12)
    private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
    // 生成序列的掩碼
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);
    // 工作機器ID(0~31)
    private long workerId;
    // 數據中心ID(0~31)
    private long dataCenterId;
    // 毫秒內序列(0~4095)
    private long sequence = 0L;
    // 上次生成ID的時間截
    private long lastTimestamp = -1L;
    /**
     * 構造函數
     * @param workerId 工作ID (0~31)
     * @param dataCenterId 數據中心ID (0~31)
     */
    public SnowIdWorkerUtil (long workerId, long dataCenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException("workerId 不符合條件");
        }
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
            throw new IllegalArgumentException("dataCenterId 不符合條件");
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
    }

    public synchronized String nextIdVar(){
        return String.valueOf(nextId());
    }
    /**
     * 線程安全,獲得下一個ID
     */
    private synchronized long nextId() {
        long timestamp = timeGen();
        // 如果當前時間小於上一次ID生成的時間戳,拋出異常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format(
                                        "時間順序異常,時間差(上次時間-現在)=%d",
                                        lastTimestamp - timestamp));
        }
        // 如果是同一時間生成的,則進行毫秒內序列
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            //毫秒內序列溢出
            if (sequence == 0) {
                //阻塞到下一個毫秒,獲得新的時間戳
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 時間戳改變,毫秒內序列重置
            sequence = 0L;
        }
        // 上次生成ID的時間截
        lastTimestamp = timestamp;
        // 移位並通過或運算拼到一起組成64位的ID
        return ((timestamp - timeToCut) << timestampLeftShift)
                | (dataCenterId << dataCenterIdShift)
                | (workerId << workerIdShift) | sequence;
    }

    /**
     * 阻塞,獲得新的時間戳
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    /**
     * 返回當前時間節點
     */
    private long timeGen() {
        return System.currentTimeMillis();
    }

    public static void main(String[] args) {
        // 參數在實際業務下需要配置管理
        SnowIdWorkerUtil idWorker = new SnowIdWorkerUtil(1, 1);
        for (int i = 0; i < 100; i++) {
            String id = idWorker.nextIdVar();
            System.out.println(id+"  "+id.length()+"位");
        }
    }
}

三、自定義實現

還有一種常見的實現思路,基於數據庫的自增主鍵ID,不過基於這個原理,卻有各種不同的實現策略。

簡單表結構:

CREATE TABLE `du_temp_id` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
  `create_time` datetime DEFAULT NULL COMMENT '創建時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='主鍵ID臨時表';

1、基於主鍵

這種模式的原理比較單調,向臨時表寫入一條記錄,藉助MySQL生成的唯一主鍵ID,然後拿出來稍微處理一下,作為各種業務場景的唯一ID使用。

@Service
public class TempIdServiceImpl implements TempIdService {

    @Resource
    private TempIdMapper tempIdMapper ;

    @Override
    public List<String> getIdList() {
        List<String> idList = new ArrayList<>() ;
        TempIdEntity tempIdEntity = new TempIdEntity ();
        tempIdEntity.setCreateTime(new Date());
        for (int i = 0 ; i < 10 ; i++){
            tempIdMapper.insert(tempIdEntity);
            idList.add(UuidUtil.getNoId(8,Long.parseLong(tempIdEntity.getId().toString()))) ;
        }
        return idList ;
    }
}

問題點:如果作為ID生成的臨時表所在的MySQL服務宕掉,那可能會影響整個業務流程,造成雪崩效應。

2、高可用集群

單服務如果不能安穩的支撐業務需求,很自然集群模式就該上場了。提供多台MySQL服務[A,B,C],處理策略也不止一種:

  • 庫設置主鍵自增策略

例如A庫[1,4,7],B庫[2,5,8],C庫[3,6,9],基於不同自增規則,生成統一的自增唯一標識。

  • 生成ID做分庫標識

這種先把ID生成,然後不同的數據庫生成的ID給一個不同的標識,例如UIDA,UIDB,UIDC。

@Service
public class TempIdServiceImpl implements TempIdService {

    @Resource
    private TempIdMapper tempIdMapper ;

    @Override
    public List<String> getRouteIdList() {
        List<String> idList = new ArrayList<>() ;
        TempIdEntity tempIdEntity = new TempIdEntity ();
        tempIdEntity.setCreateTime(new Date());
        for (int i = 0 ; i < 2 ; i++){
            tempIdMapper.insertA(tempIdEntity);
            idList.add(UuidUtil.getRouteId("UID-A",10,
                       Long.parseLong(tempIdEntity.getId().toString()))) ;
            tempIdMapper.insertB(tempIdEntity);
            idList.add(UuidUtil.getRouteId("UID-B",10,
                    Long.parseLong(tempIdEntity.getId().toString()))) ;
            tempIdMapper.insertC(tempIdEntity);
            idList.add(UuidUtil.getRouteId("UID-C",10,
                    Long.parseLong(tempIdEntity.getId().toString()))) ;
        }
        return idList ;
    }
}

結果樣例:

UID-A00001,UID-B00001,UID-C00001

UID-A00002,UID-B00002,UID-C00002

3、ID樣式優化

從數據獲取的ID基本是一個自增的整數序列,可以提供一個格式美化工具方法。

public class UuidUtil {

    private static final String ZERO = "00000000000";
    private static final String PREFIX = "UID";
    
    public static String getNoId(int length,Long id){
        String idVar = String.valueOf(id) ;
        if (idVar.length()>length){
            return PREFIX+idVar ;
        } else {
            int gapLen = length-idVar.length()-PREFIX.length() ;
            return PREFIX+ZERO.substring(0,gapLen)+idVar ;
        }
    }

    public static String getRouteId(String route,Integer length,Long id){
        String idVar = String.valueOf(id) ;
        if (idVar.length()>length){
            return route+idVar ;
        } else {
            int gapLen = length-idVar.length()-route.length() ;
            return route+ZERO.substring(0,gapLen)+idVar ;
        }
    }
}

基於不同的策略,把ID格式為統一的位數。

4、性能問題

如果在高並發的業務場景下,實時基於MySQL去生成唯一ID容易產生性能瓶頸,當然其他方法也可能產生這個問題。可以在系統空閑時間批量生成一批,放入緩存中,在使用的時候直接從緩存層取出即可。

四、源代碼地址

GitHub·地址
//github.com/cicadasmile/data-manage-parent
GitEE·地址
//gitee.com/cicadasmile/data-manage-parent

推薦閱讀:數據和架構管理

序號 標題
A01 數據源管理:主從庫動態路由,AOP模式讀寫分離
A02 數據源管理:基於JDBC模式,適配和管理動態數據源
A03 數據源管理:動態權限校驗,表結構和數據遷移流程
A04 數據源管理:關係型分庫分表,列式庫分佈式計算
C01 架構基礎:單服務.集群.分佈式,基本區別和聯繫