分布式主鍵生成算法


https://blog.csdn.net/wangpengzhi19891223/article/details/81197078

 

這篇文章總結了分布式主鍵或者唯一鍵的生成算法,文章最后有我們基於snowflow算法的思考和實踐。

分布式主鍵的生成方式分為中心化和去中心化兩大類。

中心化生成算法

中心化生成算法經典的方案主要有基於SEQUENCE區間方案、各數據庫按特定步長自增和基於redis生成自增序列三種

SEQUENCE區間方案

淘寶分布式數據層TDDL就是采用SEQUENCE方案實現了分庫分表、Master/Salve、動態數據源配置等功能。大致原理是:所有應用服務器去同一個庫獲取可使用的sequence(樂觀鎖保證一致性),得到(sequence,sequence+步長]個可被這個數據源使用的id,當應用服務器插入“步長”個數據后,再次去爭取新的sequence區間。
優勢:生成一個 全局唯一 的 連續 數字類型主鍵,延用單庫單表時的主鍵id。
劣勢:無法保證 全局遞增 。需要開發各種數據庫類型id生成器。擴容歷史數據不好遷移

操作步驟如下:
第一步:創建一張sequence對應的表。記錄每一個表的當前最大sequence,幾張邏輯表需要聲明幾個sequence;
第二步:配置sequenceDao,定義步長等信息

<bean id="sequenceDao" class="com.taobao.tddl.client.sequence.impl.DefaultSequenceDao"> <!-- 數據源 --> <property name="dataSource" ref="dataSource" /> <!-- 步長--> <property name="step" value="1000" /> <!-- 重試次數--> <property name="retryTimes" value="1" /> <!-- sequence 表名--> <property name="tableName" value="gt_sequence" /> <!-- sequence 名稱--> <property name="nameColumnName" value="BIZ_NAME" /> <!-- sequence 當前值--> <property name="valueColumnName" value="CURRENT_VALUE" /> <!-- sequence 更新時間--> <property name="gmtModifiedColumnName" value="gmt_modified" /> </bean> 

DefaultSequenceDao獲取區間源碼如下:

public SequenceRange nextRange(String name) throws SequenceException { if (name == null) { throw new IllegalArgumentException("序列名稱不能為空"); } long oldValue; long newValue; Connection conn = null; PreparedStatement stmt = null; ResultSet rs = null; for (int i = 0; i < retryTimes + 1; ++i) { try { conn = dataSource.getConnection(); stmt = conn.prepareStatement(getSelectSql()); stmt.setString(1, name); rs = stmt.executeQuery(); rs.next(); oldValue = rs.getLong(1); if (oldValue < 0) { StringBuilder message = new StringBuilder(); message.append("Sequence value cannot be less than zero, value = ").append(oldValue); message.append(", please check table ").append(getTableName()); throw new SequenceException(message.toString()); } if (oldValue > Long.MAX_VALUE - DELTA) { StringBuilder message = new StringBuilder(); message.append("Sequence value overflow, value = ").append(oldValue); message.append(", please check table ").append(getTableName()); throw new SequenceException(message.toString()); } newValue = oldValue + getStep(); } catch (SQLException e) { throw new SequenceException(e); } finally { closeResultSet(rs); rs = null; closeStatement(stmt); stmt = null; closeConnection(conn); conn = null; } try { conn = dataSource.getConnection(); stmt = conn.prepareStatement(getUpdateSql()); stmt.setLong(1, newValue); stmt.setTimestamp(2, new Timestamp(System.currentTimeMillis())); stmt.setString(3, name); stmt.setLong(4, oldValue); int affectedRows = stmt.executeUpdate(); if (affectedRows == 0) { // retry continue; } return new SequenceRange(oldValue + 1, newValue); } catch (SQLException e) { throw new SequenceException(e); } finally { closeStatement(stmt); stmt = null; closeConnection(conn); conn = null; } } throw new SequenceException("Retried too many times, retryTimes = " + retryTimes); } 

第三步:配置sequence生成器,用於獲取可使用的sequence區間,使用完后再去sequence庫獲取。

<bean id="businessSequence" class="com.taobao.tddl.client.sequence.impl.DefaultSequence"> <property name="sequenceDao" ref="sequenceDao"/> <property name="name" value="business_sequence" /> </bean> 

其中DefaultSequence源碼如下:

public class DefaultSequence implements Sequence { private final Lock lock = new ReentrantLock(); private SequenceDao sequenceDao; /** * 序列名稱 */ private String name; private volatile SequenceRange currentRange; public long nextValue() throws SequenceException { if (currentRange == null) { lock.lock(); try { if (currentRange == null) { currentRange = sequenceDao.nextRange(name); } } finally { lock.unlock(); } } long value = currentRange.getAndIncrement(); if (value == -1) { lock.lock(); try { for (;;) { if (currentRange.isOver()) { currentRange = sequenceDao.nextRange(name); } value = currentRange.getAndIncrement(); if (value == -1) { continue; } break; } } finally { lock.unlock(); } } if (value < 0) { throw new SequenceException("Sequence value overflow, value = " + value); } return value; } public SequenceDao getSequenceDao() { return sequenceDao; } public void setSequenceDao(SequenceDao sequenceDao) { this.sequenceDao = sequenceDao; } public String getName() { return name; } public void setName(String name) { this.name = name; } } 

第四步:調用

public class IbatisSmDAO extends SqlMapClientDaoSupport implements SmDAO { /**smSequence*/ private DefaultSequence businessSequence; public int insert(SmDO sm) throws DataAccessException { if (sm == null) { throw new IllegalArgumentException("Can't insert a null data object into db."); } try { sm.setId((int)businessSequence.nextValue()); } catch (SequenceException e) { throw new RuntimeException("Can't get primary key."); } getSqlMapClientTemplate().insert("MS-SM-INSERT", sm); return sm.getId(); } } 

優勢:生成一個全局唯一的連續數字類型主鍵,延用單庫單表時的主鍵id。
劣勢:無法保證全局遞增。需要開發各種數據庫類型id生成器。

各數據庫按特定步長自增

可以繼續采用數據庫生成自增主鍵的方式,為每個不同的分庫設置不同的初始值,並按步長設置為分片的個數即可,這種方式對分片個數有依賴,一旦再次水平擴展,原有的分布式主鍵不易遷移。為了預防后續庫表擴容,這邊可以采用提前約定最大支持的庫表數量,后續擴容為2的指數倍擴容。
比如:我們規定最大支持1024張分表,數據庫增長的步長為1024(即使現在的表數量只有64)。
優勢:生成一個全局唯一的數字類型主鍵,延用單庫單表時的主鍵id。當分表數沒有達到約定的1024張分表,全局不連續。
劣勢:無法保證全局遞增,也不保證單機連續。需要開發各種數據庫類型id生成器。需要依賴一個中心庫表sequence。

基於redis的方案

另一種中心化生成分布式主鍵的方式是采用Redis在內存中生成自增序列,通過redis的原子自增操作(incr接口)生成一個自增的序列。
優勢:生成一個 全局連續遞增 的數字類型主鍵。
劣勢:此種方式新增加了一個外部組件的依賴,一旦Redis不可用,則整個數據庫將無法在插入,可用性會大大下降,另外Redis的單點問題也需要解決,部署復雜度較高。

去中心化生成算法

去中心化方式無需額外部署,以jar包方式被加載,可擴展性也很好,因此更推薦使用。目前主流的去中心化生成算法有:UUID及其變種、Mongo的ObjectId、snowflake算法及其變種

UUID及其變種

UUID 是 通用唯一識別碼(Universally Unique Identifier)的縮寫,是一種軟件建構的標准,亦為開放軟件基金會組織在分布式計算環境領域的一部分。其目的,是讓分布式系統中的所有元素,都能有唯一的辨識信息,而不需要通過中央控制端來做辨識信息的指定。UUID有很多變種實現,目前最廣泛應用的UUID,是微軟公司的全局唯一標識符(GUID)。
UUID是一個由4個連字號(-)將32個字節長的字符串分隔后生成的字符串,總共36個字節長。算法的核心思想是結合機器的網卡、當地時間、一個隨即數來生成GUID。從理論上講,如果一台機器每秒產生10000000個GUID,則可以保證(概率意義上)3240年不重復。
優勢:全局唯一,各種語言都有UUID現成實現,Mysql也有UUID實現。
劣勢:36個字符組成,按照目前Mysql最常用的編碼Utf-8,每一個字符對應的索引成本是3字節,也就是一個UUID需要108個字節的索引存儲成本,是最大數字類型(8字節)的13.5倍的存儲成本。

mongodb的ObjectId

objectid有12個字節,包含時間信息(4字節、秒為單位)、機器標識(3字節)、進程id(2字節)、計數器(3字節,初始值隨機)。其中,時間位精度(秒或者毫秒)與序列位數,二者決定了單位時間內,對於同一個進程最多可產生多少唯一的ObjectId,在MongoDB中,那每秒就是2^24(16777216)。但是機器標識與進程id一定要保證是不重復的,否則極大概率上會產生重復的ObjectId。由於ObjectId生成12個字節的16進制表示,無法用現有基礎類型存儲,只能轉化為字符串存儲,對應24個字符。objectid的組成結構如下

4字節 3字節 2字節 3字節
time machine pid 自增

ObjectId生成算法的核心代碼如下:

public class ObjectId implements Comparable<ObjectId> , java.io.Serializable { final int _time; final int _machine; final int _inc; boolean _new; public ObjectId(){ _time = (int) (System.currentTimeMillis() / 1000); _machine = _genmachine; _inc = _nextInc.getAndIncrement(); _new = true; } …… } 

優勢: 全局唯一 。
劣勢:非數字類型,24個字符,按照目前Mysql最常用的編碼Utf-8,每一個字符對應的索引成本是3字節,也就是一個ObjectId需要72個字節的索引存儲成本,是最大數字類型(8字節)的9倍的存儲成本。

snowflake算法

Snowflake算法產生是為了滿足Twitter每秒上萬條消息的請求,每條消息都必須分配一條唯一的id,這些id還需要一些大致的順序(方便客戶端排序),並且在分布式系統中不同機器產生的id必須不同。Snowflake算法把時間戳,工作機器id,序列號組合在一起。生產Id的結構如下:

63 62-22 21-12 11-0
1位:2 41位:支持69.7年(單位ms) 10位:1024 12位:4096

默認情況下41bit的時間戳可以支持該算法使用到2082年,10bit的工作機器id可以支持1023台機器,序列號支持1毫秒產生4095個自增序列id。

工作機器id可以使用IP+Path來區分工作進程。如果工作機器比較少,可以使用配置文件來設置這個id是一個不錯的選擇,如果機器過多配置文件的維護是一個災難性的事情。
實施現狀:工作機器id有10位,根據我們公司目前已經未來5-10的業務量,同一個服務機器數超過1024台基本上不太可能。工作機器id推薦使用下面的結構來避免可能的重復。

9-8 7-0
用戶可指定(默認為0) 機器ip的后8位

考慮到我們公司的業務級別,同一個機房ip的后8位基本上不可能重復。后2位讓用戶指定是由於存在以下場景:
1)一個虛擬機下面可能存在兩個進程號不同的同樣服務(我們不建議,后續也希望通過運維來避免類似的部署)。如果存在這種情況,可以在JVM啟動參數中添加HostId參數,為這個這台機器的服務指定一個不同於其他服務的HostId。
2)存在前后台服務部署在同一台機器上,都操作同一個庫(建議后台服務通過調用前台的服務來操作庫表,保證庫表的單一操作)。如果存在這種情況,可以通過為前后台服務指定不同的服務編號serviceNo(只支持0,1,2,3)。
3)不同機房可能存在相同后8位ip尾號,比如興議機房為10.10.100.123 濱安機房為10.20.100.123。如果存在這種情況,可以通過 a)在其中一台機器的環境變量中重新指定一下HostId;b)不同環境配置不同的服務編號serviceNo;c)服務啟動JVM參數中為這個這台機器的服務指定一個不同於其他服務的HostId

變種snowflake算法
結合公司現狀,我們在snowflake算法的基礎上進行了部分改造,得到變種snowflake算法。我們推薦使用的分布式主鍵生成算法是變種的snowflake算法。這個算法更加充分利用了ID的位表達,比原生的snowflake算法多出1位使用。產生的ID結構如下:

63-62 61-52 51-20 19-0
2位:4 10位:1024 32位:136年(單位為s) 19位:1048560
保留位 機器碼 時間戳 自增碼

時間戳生成: 32位時間戳代表秒的話,可以表示136年,比如我們取2016年11月11日0點0分0秒作為基准,32位時間表示當前時間轉換秒數-基准時間轉換秒數
自增碼:服務數據源 原子自增的long類型變量,最大支持每秒1048560條記錄,當一秒產生超過1048560個序號時,再次請求生成序號時,會阻塞等待下一秒到達才生成新的序號。為了避免自增碼都是從0開始計數導致數據傾斜,自增碼的起始值被設定成一個隨機數。
機器碼:可以參考上面描述的方案

我們實現變種snowflake算法的核心代碼如下

    @PostConstruct public void init() { this.initHostId(); this.initTime(); this.initIncNo(); } /** * 初始化{@link #hostId} {@link #shiftedHostId} */ protected void initHostId() { if(serviceNo > 3 || serviceNo <0){ LOG.error("serviceNo只支持0、1、2、3"); throw new ShardingJdbcException("serviceNo只支持0、1、2、3"); } if (hostId != 0) { this.shiftedHostId = this.hostId << this.hostTimeRule.getHostOffset(); LOG.info("屬性注入HostId。HostId:{}", hostId); LOG.info("初始化Id生成器。HostId:{},ShiftHostId:{}", hostId, shiftedHostId); return; } // 從JVM參數中,獲取系統Id String host = System.getProperty(HOST_ID); if (host != null) { this.hostId = Integer.valueOf(host); LOG.info("從JVM參數中獲取HostId。HostId:{}", hostId); } else { // 從環境中獲取系統ID host = System.getenv(HOST_ID); if (host != null) { this.hostId = Integer.valueOf(host); LOG.info("從系統環境中獲取HostId。HostId:{}", hostId); } else if (useSystemIpAsHostId) { //從網卡讀取IP地址,轉換成HostId。取后8bit位,hostId為service*256+ip后8位 String ip = IpUtil.getLocalIPv4(); this.hostId = serviceNo*256 + (int) (IpUtil.convertIPv4(ip) & 255); LOG.info("從網卡中獲取HostId。serviceNo:{},IP:{}, HostId:{}", serviceNo,ip, hostId); } else { LOG.error("沒有設置HostId,也沒有開啟useSystemIpAsHostId"); throw new ShardingJdbcException("必須設置HostId,或者開啟useSystemIpAsHostId為true"); } } this.shiftedHostId = this.hostId << this.hostTimeRule.getHostOffset(); LOG.info("初始化Id生成器。HostId:{},ShiftHostId:{}", hostId, shiftedHostId); } /** * 初始化{@link #baseTime }{@link #timeBaseLine } {@link #shiftedTime } */ protected void initTime() { if (timeBaseLine > System.currentTimeMillis()) { LOG.error("時間戳底線時間timeBaseLine不能大於當前毫秒級時間戳。當前時間:{},timeBaseTime:{}", System.currentTimeMillis(), timeBaseLine); throw new ShardingJdbcException("時間戳底線時間timeBaseLine不能大於當前毫秒級時間戳。"); } LOG.info("時間底線TimeBaseLine:{},時間跨度TimeGap:{}", timeBaseLine, timeGap); long baseTime = System.currentTimeMillis(); currentShiftedTime = (((baseTime - timeBaseLine) / timeGap)%(1L << hostTimeRule.getTimeLength()))<< hostTimeRule.getTimeOffset(); } /** * 初始化{@link #incNo} */ protected void initIncNo() { if (!randomIncNo) { LOG.debug("不需要隨機IncNo。"); return; } //使用隨機初始化IncNo startIncNo = (int) ((1L << hostTimeRule.getIncNoLength()) * Math.random()); //這里如果事先設置IncNo屬性,就不要開啟隨機IncNo。 if (incNo == 0) { incNo = startIncNo; LOG.info("設置隨機IncNo成功。IncNo:{}", randomIncNo); } else { LOG.info("設置隨機IncNo失敗。請確認初始化IncNo為0,即不要設置IncNo屬性,或者關閉randomIncNo屬性!IncNo:{}", incNo); throw new ShardingJdbcException("設置隨機IncNo失敗。請確認初始化IncNo為0,即不要設置IncNo屬性,或者關閉randomIncNo屬性!"); } maxIncNo = startIncNo + (1 << hostTimeRule.getIncNoLength()); } public synchronized long getAndAdd(){ return getAndAdd(1); } public synchronized long getAndAdd(int size){ long currentIncNo = 0; //當一秒內請求超過 1 << hostTimeRule.getIncNoLength() 時要等待下一秒 while(true){ getShiftTime(); currentIncNo = incNo; if(currentShiftedTime == shiftedTime){ incNo = incNo+size; if(currentIncNo >= maxIncNo){ LOG.info("當前時間分片請求分布式主鍵id的自增值:{}達到算法瓶頸,需要等下一個時間分片才能創建.起始偏移:{},每一個時間分片最大支持生成個數:{}",new Object[]{currentIncNo,startIncNo,(1 << hostTimeRule.getTimeLength())}); continue; } }else{ currentShiftedTime = shiftedTime; incNo = startIncNo; //從頭開始計數 } break; } return ((currentIncNo)%(1L << hostTimeRule.getIncNoLength())) << hostTimeRule.getIncNoOffset(); } private void getShiftTime(){ long baseTime = System.currentTimeMillis(); shiftedTime = (((baseTime - timeBaseLine) / timeGap)%(1L << hostTimeRule.getTimeLength()))<< hostTimeRule.getTimeOffset(); } 
    @Override public Number generateId() { return shiftedHostId + shiftedTime + getAndAdd(); } 

snowflake算法的優勢和劣勢如下:
優勢:在服務器規模不是很大(不超過1024條件) 全局唯一 ,單機遞增 ,是數字類型,存儲索引成本低。
劣勢:機器規模大於1024無法支持,需要運維配合解決單機部署多個同服務進程問題。

帶偏移的snowflake算法
什么是帶偏移的snowflake算法?指的是某個變量的后多少位和另一個字段的后多少位有相同的二進制,從而這兩個變量具有相同的偏移。也就是一個變量的生成依賴另一個字段,兩者具有相同的偏移量。我們也可以用槽位來理解,就是具有相同位偏移,從而保證取模運算之后這兩個變量會被分到同一個槽中。
舉個栗子,當訂單數量非常大時,需要對訂單表做分庫分表,查詢維度分為患者維度(對應買家維度)和醫生維度(對應賣家維度)。患者就診表以及醫生接單表的數量和訂單表的數量是一樣的。同理也需要對患者就診表以及醫生接單表進行分庫分表。這樣就存在3個分庫分表。
通過偏移綁定,讓訂單的生成id的后多少位(比如后10位)和用戶id的后多少位(比如后10位)具有相同的偏移。也就是訂單生成部分依賴患者id。這樣通過訂單id或者患者id進行取模運算(mod 1024)都能定位到同一個分庫分表(槽),這樣患者就診表和訂單表就是同一個表,從而將3個分庫分表減少為2個分庫分表。以最大支撐分庫分表數量為1024,這樣我們后10位用於偏移。帶偏移的snowflake算法產生的ID結構如下:

64-63 62-53 52-24 23-10 9-0
1位:符號位 10位:1024 29位:17年 14位:16384 10位:1024個slot
保留位 機器碼 時間戳 自增碼 偏移位(槽位)

這樣生成的id能夠支撐17年;最大支持1024台應用機器同時生產數據;最大支持同一個用戶每秒產生16384條記錄。

核心代碼如下

    @Override public Long generate(Long item) { return shiftedHostId + shiftedTime + getAndAdd() + ((item & hostTimeRule.getBiasedMask()) << hostTimeRule.getBiasedOffset()); } @Override public Long batchGenerateMinId(Long item, int size) { return shiftedHostId + shiftedTime + getAndAdd(size) + (((item & hostTimeRule.getBiasedMask())) << hostTimeRule.getBiasedOffset()); } 

帶偏移的snowflake算法的優勢和劣勢如下:
優勢:在服務器規模不是很大(不超過1024條件) 全局唯一,是數字類型,存儲索引成本低。通過偏移綁定,能減少一個分庫分表。
劣勢:不保證單機遞增,機器規模大於1024無法支持,需要運維配合解決單機部署多個同服務進程問題。

 

 

https://yq.aliyun.com/articles/648038


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM