天天看點

一個PHP實作的ID生成器

通常來說,不管使用什麼資料庫,表裡都有一個名為 id 的主鍵,既然是主鍵,那麼必然要滿足唯一性,對于 MySQL 使用者來說,它多半是一個 auto_increment 自增字段,也有一些别的使用者喜歡使用 UUID 做主鍵,不過對 MySQL(特别是 InnoDB)來說,UUID 通常不是一個好選擇,因為聚簇索引要求實體資料按照主鍵排序,而 UUID 本身是無序的,是以會帶來很多不必要的 IO 消耗。于是乎我們得到一個結論:ID 最好是順序的唯一值。

如此說來,就用 MySQL 的 auto_increment 自增字段不就好了?問題是這樣無法滿足高可用性,雖然可以通過多台伺服器設定不同的 auto_increment 步長來提升可用性,但資料庫本身始終就是那塊最短的木闆。至于解決方案,網上已經有很多類似的讨論:

  • 細聊分布式ID生成方法
  • 業務系統需要什麼樣的ID生成器
  • 分布式Unique ID的生成方法一覽
  • 微信序列号生成器架構設計及演變

最流行的解決方案,當然是 twitter 的 snowflake,其大緻含義是說:為了避免單點故障,在多個節點上運作 ID 生成器服務,每個節點都有自己獨立的辨別,ID 以時間因子為字首,雖然不同的伺服器時間可能存在差異,不能保證絕對的順序,但是整體的趨勢還是可以認為是順序的,IO 負擔可以忽略,同時以一個計數器為字尾,進而保證唯一性。

網上現有的開源 ID 生成器,比如 Chronos,都是運作為服務的形式,不過對我而言,這樣有些太重了,于是我用 PHP 實作了一個非服務化的簡版 ID 生成器,雖然它很簡單,但是它并不簡陋,實作了 snowflake 要求的功能:

<?php

class Sequence
{
    const EPOCH = 1000000000000;

    const TIME_BITS  = 41;
    const NODE_BITS  = 10;
    const COUNT_BITS = 10;

    private $node = 0;

    private $ttl = 10;

    public function __construct($node)
    {
        $max = $this->max(self::NODE_BITS);

        if (is_int($node) === false || $node > $max || $node < 0) {
            throw new \InvalidArgumentException('node');
        }

        $this->node = $node;
    }

    public function generate($time = null)
    {
        if ($time === null) {
            $time = (int)(microtime(true) * 1000);
        }

        return ($this->time($time) << (self::NODE_BITS + self::COUNT_BITS)) |
               ($this->node << self::COUNT_BITS) |
               ($this->count($time));
    }

    public function restore($id)
    {
        $binary = decbin($id);

        $position = -(self::NODE_BITS + self::COUNT_BITS);

        return array(
            'time'  => bindec(substr($binary, 0, $position)) + self::EPOCH,
            'node'  => bindec(substr($binary, $position, - self::COUNT_BITS)),
            'count' => bindec(substr($binary, - self::COUNT_BITS)),
        );
    }

    public function setTTL($ttl)
    {
        $this->ttl = $ttl;
    }

    private function time($time)
    {
        $time -= self::EPOCH;

        $max = $this->max(self::TIME_BITS);

        if (is_int($time) === false || $time > $max || $time < 0) {
            throw new \InvalidArgumentException('time');
        }

        return $time;
    }

    private function count($time)
    {
        $key = "seq:count:" . ($time % ($this->ttl * 1000));

        while (!$count = apcu_inc($key)) {
            apcu_add($key, mt_rand(0, 9), $this->ttl);
        }

        $max = $this->max(self::COUNT_BITS);

        if ($count > $max) {
            throw new \UnexpectedValueException('count');
        }

        return $count;
    }

    private function max($bits)
    {
        return -1 ^ (-1 << $bits);
    }
}

?>           

複制

本文中的實作利用 apcu 來儲存資料,但是并不需要以服務的形式存在。以 41 位毫秒時間為例,理論上最大值可以儲存到 2039-09-07,如果考慮到 EPOCH,還可以儲存的時間更久遠點,以 1000000000000 為例,則可以儲存到 2071-05-16,此外我們給節點留了 10 位,計數器留了 10 位,理論上可以容納最多 1023 個節點,每個節點每毫秒最多 1023 個 ID。這些門檻值基本都足夠了,多半還沒到達上限,系統就已經挂了。

BTW:如果是一些非親緣性的 PHP 程序共同使用一個 id 生成器的話,比如 php-fpm 和 php-cli 共同使用一個 id 生成器,那麼 apcu 并不合适,此時需要使用 libshmcache。

需要說明的是,最初我的設計并不是以毫秒為為機關,而是以秒為機關,但是以秒為機關有一個問題:假設在一秒内重新開機 php-fpm,那麼有可能會産生不唯一的值,雖然可以通過在重新開機腳本裡 sleep 一秒來規避問題,但是畢竟太麻煩了,于是我索性以毫秒為機關來設計,因為我們不可能在一毫秒的間隔内重新開機 php-fpm,是以這個問題就不存在了。

不過,如果伺服器出現時間回退現象,那麼依然可能産生不唯一的值,但需要滿足幾個條件:首先,伺服器時間發生了回退;其次,回退後生成 ID 時的時間恰好在以前使用過;最後,伺服器因為 LRU 等原因清除了相關的緩存。要滿足這些條件,基本是很難的,也就是說,對于絕大部分 PHP 項目而言,本文的代碼可以認為是足夠強壯的。

此外,生成的 ID 最好别直接用,不然别人可以反解出其中的資料,比如你有多少台伺服器等等,解決辦法是在應用層用 hashids 編碼及解碼,如此一來,資料庫裡儲存的還是原始的 ID(Bigint),但是使用者看到的卻是 HASH ID,進而更好的保護了資料的安全。