衆所周知, 在分佈式全局惟一ID生成器方案中, 由Twitter開源的SnowFlake算法對比美團Leaf爲表明的須要部署的發號器算法, 因其有性能高, 代碼簡單, 不依賴第三方服務, 無需獨立部署服務等優勢, 在通常狀況下已經能知足絕大多數系統的需求, 原生SnowFlake, 百度UidGenerator這類基於劃分命名空間原理的算法已經積累了大量用戶;html
使用原生的雪花算法其默認生成的是64bit長整型, 若是以ID和前端的JS進行交互時會出現精度丟失(最後兩位數字變成00) 而致使最終系統報錯: 找不到ID; 廢話, 最後兩位都變成00了那確定找不到啊! 究其緣由是由於JS的Number類型精度最高只有53bit, 致使JS其最大安全值只有2^53 = 9007199254740992 算法生成的18位數字妥妥的超標了啊;前端
解決方法有: 避免使用ID進行交互, 後端將long類型的ID映射爲String, 使JS兼容64bit長整型, 改造SnowFlake縮短位數;java
既然是由於位數太長了, 那咱們縮短位數不就行了嗎? JS的53bit精度, 也有最大值是億億, 也沒有誰會有億億個數據吧, 那咱們仔細研究一下這個雪花算法, 把他縮短一下, 程序員如何向公司證實本身的價值不就在於反覆造輪子嗎? 管他方的輪子好用仍是圓的輪子好用;git
原生SnowFlake默認結構以下:程序員
原生SnowFlake中的空間劃分:
1, 高位1bit固定0表示正數
2, 41bit毫秒時間戳
3, 10bit機器編號, 最高可支持1024個節點
4, 12bit自增序列, 單節點最高可支持4096個ID/ms
複製代碼
由上面原生算法結構能夠看到, 影響最終生成ID長度最大的是毫秒時間戳, 它佔了整整41bit換成10進制就是佔了13位數, 不減小這個最終位數確定就下不去; 考慮到絕大部分公司並不存在這麼高的數據庫併發也沒有1024臺這麼多的機器集羣;github
時間戳由毫秒改成32bit的秒級時間戳, 機器編碼縮短爲5bit, 剩下16bit作自增序列;算法
機器編號也能夠減小, 最終結果以下圖 ↓↓↓:數據庫
縮短算法後空間劃分
1, 高位32bit做爲秒級時間戳, 時間戳減去固定值(2019年時間戳), 最高可支持到2106年(1970 + 2^32 / 3600 / 24 / 365 ≈ 2106)
2, 5bit做爲機器標識, 最高可部署32臺機器
3, 最後16bit做爲自增序列, 單節點最高每秒 2^16 = 65536個ID
PS: 若是須要部署更多節點, 能夠適當調整機器位和自增序列的位長, 如機器位7bit, 自增序列14bit, 這樣一來就變成了支持2^7=128個節點, 單節點每秒2^14=16384個自增序列
複製代碼
趨勢自增依賴於高位的時間戳, 若是服務器因同步時間等操做致使了時鐘回撥, 那麼將會有可能產生重複ID, 對此個人解決方法是將32個節點ID再次拆分, 0~15做爲主節點ID, 16~31做爲備份節點ID, 當主節點檢測到時鐘回撥時, 啓用對應的備份節點繼續提供服務, 若是不巧極端狀況併發極高, 使用備份節點時一秒內耗盡65536個序列, 則借調下一秒的未使用序列, 直到主節點時鐘追回;
備份節點可在主節點秒內序列耗盡時接管繼續提供服務, 或者在時鐘回撥時接管服務, 這樣一來最大支持的機器就只剩下16臺了, 雖然少了點, 可是也已經足夠知足通常小型企業的需求了, 同時也提升了服務的可靠性;後端
主節點與備份節點關係: 主節點爲0時對應備份節點爲0 + 16 = 16, 主節點爲1時則對應1 + 16 = 17, 主節點爲2時則對應2 + 16 = 18 ......緩存
具體代碼則體現以下: 節點標識爲5bit時, BACK_WORKER_ID_BEGIN是備份節點ID的最小值 = 16, 主節點 + 16 獲得備份節點
((WORKER_ID ^ BACK_WORKER_ID_BEGIN) << WORKER_SHIFT_BITS)
複製代碼
當遇到極端狀況每秒併發超過65536時則會遇到該秒內再無可分配ID的問題, 爲了解決這個問題, 可在秒內序列用盡時啓用備份節點, 這個一個節點ID則每秒能夠得到翻倍的ID, 當備份節點也不足時, 最後能夠考慮由備份節點直接啓用下一秒的未分配序列繼續提供服務, 這樣理論上就得到了無限容量的秒內可分配ID(只要機器性能跟得上能夠無限借調下一秒, 直到1秒後主節點追回時間差由主節點繼續提供服務, 以此循環往復生生不息~)
if (0L == (++sequence & SEQUENCE_MAX)) {
// 上面自增序列已自增1, 回滾這個自增操做確保下次進入時依舊觸發條件
sequence--;
// 秒內序列用盡, 使用備份節點繼續提供服務
return nextIdBackup(timestamp);
}
複製代碼
爲了代碼邏輯清晰簡單, 主節點和備份節點生成直接複製爲結構類似的兩個方法
/** * 雪花算法分佈式惟一ID生成器<br> * 每一個機器號最高支持每秒65535個序列, 當秒序列不足時啓用備份機器號, 若備份機器也不足時借用備份機器下一秒可用序列<br> * 53 bits 趨勢自增ID結構以下: * * |00000000|00011111|11111111|11111111|11111111|11111111|11111111|11111111| * |-----------|##########32bit 秒級時間戳##########|-----|-----------------| * |--------------------------------------5bit機器位|xxxxx|-----------------| * |-----------------------------------------16bit自增序列|xxxxxxxx|xxxxxxxx| * * @author: yangzc * @date: 2019-10-19 **/
@Slf4j
public class SequenceUtils {
/** 初始偏移時間戳 */
private static final long OFFSET = 1546300800L;
/** 機器id (0~15 保留 16~31做爲備份機器) */
private static final long WORKER_ID;
/** 機器id所佔位數 (5bit, 支持最大機器數 2^5 = 32)*/
private static final long WORKER_ID_BITS = 5L;
/** 自增序列所佔位數 (16bit, 支持最大每秒生成 2^16 = 65536) */
private static final long SEQUENCE_ID_BITS = 16L;
/** 機器id偏移位數 */
private static final long WORKER_SHIFT_BITS = SEQUENCE_ID_BITS;
/** 自增序列偏移位數 */
private static final long OFFSET_SHIFT_BITS = SEQUENCE_ID_BITS + WORKER_ID_BITS;
/** 機器標識最大值 (2^5 / 2 - 1 = 15) */
private static final long WORKER_ID_MAX = ((1 << WORKER_ID_BITS) - 1) >> 1;
/** 備份機器ID開始位置 (2^5 / 2 = 16) */
private static final long BACK_WORKER_ID_BEGIN = (1 << WORKER_ID_BITS) >> 1;
/** 自增序列最大值 (2^16 - 1 = 65535) */
private static final long SEQUENCE_MAX = (1 << SEQUENCE_ID_BITS) - 1;
/** 發生時間回撥時容忍的最大回撥時間 (秒) */
private static final long BACK_TIME_MAX = 1L;
/** 上次生成ID的時間戳 (秒) */
private static long lastTimestamp = 0L;
/** 當前秒內序列 (2^16)*/
private static long sequence = 0L;
/** 備份機器上次生成ID的時間戳 (秒) */
private static long lastTimestampBak = 0L;
/** 備份機器當前秒內序列 (2^16)*/
private static long sequenceBak = 0L;
static {
// 初始化機器ID
// 僞代碼: 由你的配置文件獲取節點ID
long workerId = your configured worker id;
if (workerId < 0 || workerId > WORKER_ID_MAX) {
throw new IllegalArgumentException(String.format("cmallshop.workerId範圍: 0 ~ %d 目前: %d", WORKER_ID_MAX, workerId));
}
WORKER_ID = workerId;
}
/** 私有構造函數禁止外部訪問 */
private SequenceUtils() {}
/** * 獲取自增序列 * @return long */
public static long nextId() {
return nextId(SystemClock.now() / 1000);
}
/** * 主機器自增序列 * @param timestamp 當前Unix時間戳 * @return long */
private static synchronized long nextId(long timestamp) {
// 時鐘回撥檢查
if (timestamp < lastTimestamp) {
// 發生時鐘回撥
log.warn("時鐘回撥, 啓用備份機器ID: now: [{}] last: [{}]", timestamp, lastTimestamp);
return nextIdBackup(timestamp);
}
// 開始下一秒
if (timestamp != lastTimestamp) {
lastTimestamp = timestamp;
sequence = 0L;
}
if (0L == (++sequence & SEQUENCE_MAX)) {
// 秒內序列用盡
// log.warn("秒內[{}]序列用盡, 啓用備份機器ID序列", timestamp);
sequence--;
return nextIdBackup(timestamp);
}
return ((timestamp - OFFSET) << OFFSET_SHIFT_BITS) | (WORKER_ID << WORKER_SHIFT_BITS) | sequence;
}
/** * 備份機器自增序列 * @param timestamp timestamp 當前Unix時間戳 * @return long */
private static long nextIdBackup(long timestamp) {
if (timestamp < lastTimestampBak) {
if (lastTimestampBak - SystemClock.now() / 1000 <= BACK_TIME_MAX) {
timestamp = lastTimestampBak;
} else {
throw new RuntimeException(String.format("時鐘回撥: now: [%d] last: [%d]", timestamp, lastTimestampBak));
}
}
if (timestamp != lastTimestampBak) {
lastTimestampBak = timestamp;
sequenceBak = 0L;
}
if (0L == (++sequenceBak & SEQUENCE_MAX)) {
// 秒內序列用盡
// logger.warn("秒內[{}]序列用盡, 備份機器ID借取下一秒序列", timestamp);
return nextIdBackup(timestamp + 1);
}
return ((timestamp - OFFSET) << OFFSET_SHIFT_BITS) | ((WORKER_ID ^ BACK_WORKER_ID_BEGIN) << WORKER_SHIFT_BITS) | sequenceBak;
}
}
複製代碼
copy上面的ID生成算法你會發現有個報錯找不到SystemClock.now(), 由於它並非JDK自帶的類, 使用這個工具類生成時間戳的緣由是通過實測發現 Linux環境中高併發下System.currentTimeMillis()這個API對比Windows環境有近百倍的性能差距;
此問題的緣由分析博文: 緩慢的 System.currentTimeMillis();
百度簡單搜索發現已有不少解決方案, 最簡單直接的是起一個線程定時維護一個毫秒時間戳以覆蓋JDK的System.currentTimeMillis(), 雖然這樣當然會形成必定的時間精度問題, 但咱們的ID生成算法是秒級的Unix時間戳, 也不在意這幾十微秒的偏差, 換來的倒是百倍的性能提高, 這是徹底值得的支出;
代碼以下:
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/** * 緩存時間戳解決System.currentTimeMillis()高併發下性能問題<br> * 問題根源分析: http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html * * @author: yangzc * @date: 2019-10-19 **/
public class SystemClock {
private final long period;
private final AtomicLong now;
private SystemClock(long period) {
this.period = period;
this.now = new AtomicLong(System.currentTimeMillis());
scheduleClockUpdating();
}
/** * 嘗試下枚舉單例法 */
private enum SystemClockEnum {
SYSTEM_CLOCK;
private SystemClock systemClock;
SystemClockEnum() {
systemClock = new SystemClock(1);
}
public SystemClock getInstance() {
return systemClock;
}
}
/** * 獲取單例對象 * @return com.cmallshop.module.core.commons.util.sequence.SystemClock */
private static SystemClock getInstance() {
return SystemClockEnum.SYSTEM_CLOCK.getInstance();
}
/** * 獲取當前毫秒時間戳 * @return long */
public static long now() {
return getInstance().now.get();
}
/** * 起一個線程定時刷新時間戳 */
private void scheduleClockUpdating() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "System Clock");
thread.setDaemon(true);
return thread;
});
scheduler.scheduleAtFixedRate(() -> now.set(System.currentTimeMillis()), period, period, TimeUnit.MILLISECONDS);
}
}
複製代碼
瞭解了主流的各大公司開源的分佈式ID生成方式最終選中了雪花算法, 經過這次改造總算是深刻了解了雪花算法的原理了, 同時也複習了一遍簡單加減乘處的位運算, 總的來講收穫仍是頗豐的, 出來工做的時間也不算長, 在程序員大軍中還算是年輕的一股經驗有所不足, 若有錯誤請多指教, 藉此慢慢培養本身寫博客的習慣, 爲了錢錢, 衝鴨!