LaravelのorderedUuidはversion4っぽいorderedなUUIDを生成している

Str::orderedUuid()は先頭の48ビットでタイムスタンプで表しているため、順序が保証されるらしい。

はじめに

Laravelのmigrationsでテーブルを作成する際は、 $table->id() としてidカラムがPKになることが多いかと思いですが、AUTO_INCREMENTだと都合が悪い場合もあります。その代替としては、GUIDをPKとする方法が一般的かと思われます。

ただし、アプリケーションでランダムに作成されたGUIDはそれ単体では連続性が保証されないので、
INSERTのオーバーヘッドが増加する可能性があります。

Laravelでは Str::orderedUuid() というメソッドでtime-orderedなUUID(version 4)を生成することが出来ます。
これを利用してorderedなUUIDをPKにすることで、INSERT時のデメリットを解消出来るということなのですが、

  • そもそもUUIDなのにtime-orderedってどういうことなのか?
  • どのようにtime-orderedを実現しているのか?

というところが気になったのでソースを深ぼって見ました。

Str::orderedUuid

とりあえず Str:orderedUuid の中身を見てみる。

/**
 * Generate a time-ordered UUID (version 4).
 *
 * @return \Ramsey\Uuid\UuidInterface
 */
public static function orderedUuid()
{
    if (static::$uuidFactory) {
        return call_user_func(static::$uuidFactory);
    }

    $factory = new UuidFactory;

    $factory->setRandomGenerator(new CombGenerator(
        $factory->getRandomGenerator(),
        $factory->getNumberConverter()
    ));

    $factory->setCodec(new TimestampFirstCombCodec(
        $factory->getUuidBuilder()
    ));

    return $factory->uuid4();
}

RandomGeneratorにCombGeneratorを、CodecにTimestampFirstCombCodecを指定し、UuidFactory::uuid4 を呼び出しています。RandomGeneratorとCodecがどのように使われているかは後述するとして、UuidFactory::uuid4 の中身を見てみます。

public function uuid4(): UuidInterface
{
    $bytes = $this->randomGenerator->generate(16);

    return $this->uuidFromBytesAndVersion($bytes, 4);
}

ここで先程指定したRandomGeneratorのgenerateを呼び出しています。そこで生成されたバイト文字列とバージョンを示す4を引数として uuidFromBytesAndVersion を呼び出しています。

/**
 * Returns an RFC 4122 variant Uuid, created from the provided bytes and version
 *
 * @param string $bytes The byte string to convert to a UUID
 * @param int $version The RFC 4122 version to apply to the UUID
 *
 * @return UuidInterface An instance of UuidInterface, created from the
 *     byte string and version
 *
 * @psalm-pure
 */
private function uuidFromBytesAndVersion(string $bytes, int $version): UuidInterface
{
    /** @var array $unpackedTime */
    $unpackedTime = unpack('n*', substr($bytes, 6, 2));
    $timeHi = (int) $unpackedTime[1];
    $timeHiAndVersion = pack('n*', BinaryUtils::applyVersion($timeHi, $version));

    /** @var array $unpackedClockSeq */
    $unpackedClockSeq = unpack('n*', substr($bytes, 8, 2));
    $clockSeqHi = (int) $unpackedClockSeq[1];
    $clockSeqHiAndReserved = pack('n*', BinaryUtils::applyVariant($clockSeqHi));

    $bytes = substr_replace($bytes, $timeHiAndVersion, 6, 2);
    $bytes = substr_replace($bytes, $clockSeqHiAndReserved, 8, 2);

    if ($this->isDefaultFeatureSet) {
        return LazyUuidFromString::fromBytes($bytes);
    }

    return $this->uuid($bytes);
}

uuidFromBytesAndVersion では引き渡されたバイト文字列のうち、versionとvariantを示す桁を置換しています。最後にそのバイト文字列からUuidクラスをnewして返却しています。

要するに Str:orderedUuid では、「RandomGeneratorで生成されたバイト文字列からUuidを生成している」ということのようです。

CombGenerator

CombGeneratorは COMB と呼ばれる連続性を持ったGUIDを生成します。
当該クラスのPhpDocに生成方法についての記述があります。

/**
 * CombGenerator generates COMBs (combined UUID/timestamp)
 *
 * The CombGenerator, when used with the StringCodec (and, by proxy, the
 * TimestampLastCombCodec) or the TimestampFirstCombCodec, combines the current
 * timestamp with a UUID (hence the name "COMB"). The timestamp either appears
 * as the first or last 48 bits of the COMB, depending on the codec used.
 *
 * By default, COMBs will have the timestamp set as the last 48 bits of the
 * identifier.
 * (略)
 */

DeepL翻訳にそのまま突っ込んだのが以下。

CombGenerator は StringCodec (そして、その代理として
TimestampLastCombCodec) または TimestampFirstCombCodec と共に使われる場合、CombGenerator は現在のタイムスタンプをUUIDと結合します。
タイムスタンプとUUIDを組み合わせます(”COMB “という名前に由来します)。タイムスタンプは
タイムスタンプは、使用するコーデックに応じて、COMBの最初または最後の48ビットとして表示されます。
デフォルトでは、COMBはタイムスタンプを識別子の最後の48ビットとして設定されます。

CombGeneratorがやっていることは、COMBと呼ばれるUUIDとtimestampを組み合わせた (combined) バイト文字列を生成するところまでで、timestampの48ビットをどこに割り当てるかはCodecによりけり、ということが窺えます。以下はその処理を司るメソッドです。

/**
 * @throws InvalidArgumentException if $length is not a positive integer
 *     greater than or equal to CombGenerator::TIMESTAMP_BYTES
 *
 * @inheritDoc
 */
public function generate(int $length): string
{
    if ($length < self::TIMESTAMP_BYTES) {
        throw new InvalidArgumentException(
            'Length must be a positive integer greater than or equal to ' . self::TIMESTAMP_BYTES
        );
    }

    $hash = '';
    if (self::TIMESTAMP_BYTES > 0 && $length > self::TIMESTAMP_BYTES) {
        $hash = $this->randomGenerator->generate($length - self::TIMESTAMP_BYTES);
    }

    $lsbTime = str_pad(
        $this->converter->toHex($this->timestamp()),
        self::TIMESTAMP_BYTES * 2,
        '0',
        STR_PAD_LEFT
    );

    return (string) hex2bin(
        str_pad(
            bin2hex($hash),
            $length - self::TIMESTAMP_BYTES,
            '0'
        )
        . $lsbTime
    );
}

大まかには、以下の流れで処理されていきます。

  1. 生成する文字列からTIMESTAMP_BYTESの桁数を除いた桁数の文字列を生成する
  2. 現在のtimestampを16進表記に置き換えた文字列を生成する
  3. 1の文字列の末尾に2を結合する

TimestampFirstCombCodec

生成したUuidクラスを文字列に変換する際にCodecによるencodeが呼び出されます。

/**
 * @psalm-return non-empty-string
 */
public function toString(): string
{
    return $this->codec->encode($this);
}

ここに来てようやく TimestampFirstCombCodec が使われるのです。

encodeの処理自体は比較的シンプルで、

  1. CombGeneratorで生成したバイト文字列の末尾から48ビット(timestamp)と先頭の48ビットを入れ替える
  2. UUIDのフォーマットに合わせて桁を区切り、 “-” でつなげる

というものになっています。

/**
 * @psalm-return non-empty-string
 * @psalm-suppress MoreSpecificReturnType we know that the retrieved `string` is never empty
 * @psalm-suppress LessSpecificReturnStatement we know that the retrieved `string` is never empty
 */
public function encode(UuidInterface $uuid): string
{
    $bytes = $this->swapBytes($uuid->getFields()->getBytes());

    return sprintf(
        '%08s-%04s-%04s-%04s-%012s',
        bin2hex(substr($bytes, 0, 4)),
        bin2hex(substr($bytes, 4, 2)),
        bin2hex(substr($bytes, 6, 2)),
        bin2hex(substr($bytes, 8, 2)),
        bin2hex(substr($bytes, 10))
    );
}

/**
 * Swaps bytes according to the timestamp-first COMB rules
 */
private function swapBytes(string $bytes): string
{
    $first48Bits = substr($bytes, 0, 6);
    $last48Bits = substr($bytes, -6);

    $bytes = substr_replace($bytes, $last48Bits, 0, 6);
    $bytes = substr_replace($bytes, $first48Bits, -6);

    return $bytes;
}

結論

Str::orderedUuid が生成する文字列は、timestampとUUIDを組み合わせて得られる連続性が保証されたUUIDである、といえます。

冒頭の問には以下の通りの回答となるかと思います。

  • そもそもUUIDなのにtime-orderedってどういうことなのか?
    → versionを示す桁は4になるが、UUID version 4と似て非なるもの
  • どのようにtime-orderedを実現しているのか?
    → 先頭48ビットでタイムスタンプを表してtime-orderedを実現している

おわりに

正直なところ、Str::orderedUuid については「ソート出来るUUID」ぐらいの認識しかなく、あまり疑問を持たずに使ってしまっていました。。
「良くわからないものはちゃんと調べてから使う」というのは当たり前のようで案外出来ないものですね。

参考資料

Laravel: The mysterious “Ordered UUID”
https://itnext.io/laravel-the-mysterious-ordered-uuid-29e7500b4f8

The Cost of GUIDs as Primary Keys
https://www.informit.com/articles/printerfriendly/25862

RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace
https://www.rfc-editor.org/rfc/rfc4122.html#section-4.1

タイトルとURLをコピーしました