ヒトリ歩き

愚痴とかいろいろ書きます

ソート可能な一意な値を生成するULIDが超絶便利

UUIDは一意な値だが、ソートできない問題がある。
その課題を解決したソート可能な一意な値を生成できるULIDは非常に便利!!

ULIDとは

ULIDは、ソート可能な一意の識別子。
辞書順にソート可能であり、UUIDと128bitの互換性がある。
ULIDの構造は、48bitがタイムスタンプとなっており、80bitがランダムな値。

インストール方法

python-ulidをインストールするだけ。

pip install python-ulid

使ってみた

基本的な使い方は下記のページに載っている。

github.com

  • 現在のタイムスタンプからULIDオブジェクトを生成

現在のタイムスタンプからULIDオブジェクトを生成する場合は、ULIDを実行する。

# 現在のタイムスタンプからULIDオブジェクトを生成
ulid = ULID()
print("ULID = " + str(ulid))
  • タイムスタンプ指定でULIDオブジェクトを生成

指定のタイムスタンプからULIDオブジェクトを生成する場合、ULIDクラスのfrom_datetimeクラスメソッドを実行する。

# 2023-03-02 18:00:00 からULIDオブジェクトを生成
specfic_datetime = datetime.datetime.strptime("2023-03-02 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
ulid = ULID.from_datetime(specfic_datetime)
print("ULID = " + str(ulid))
  • ULIDに含まれる日時を確認

ULIDに含まれる日時を確認する場合、datetimeプロパティを確認する。

# ulidに含まれる日時を確認
print("Datetime = " + ulid.datetime.strftime('%Y-%m-%d %H:%M:%S'))
  • ULIDからUUIDを生成

ULIDからUUIDを生成する場合、to_uuidメソッドを実行する。

# UUIDに変換
uuid_from_ulid = ulid.to_uuid()
print("UUID = " + str(uuid_from_ulid))
  • UUIDからULIDを生成

UUIDからULIDオブジェクトを生成する場合、ULIDクラスのfrom_uuidクラスメソッドを実行する。

# UUIDからulidを生成
ULID.from_uuid(uuid_from_ulid)
print("ULID from uuid = " + str(ulid2))

ソートしてみる

日時指定でULIDオブジェクトを生成する。このときに1日単位でずらして生成。 順序をばらばらに挿入したリストを生成して、sorted関数でソートした結果を確認する。

def sort_execute():
    
    # 日時指定で1日ずらして、ULIDオブジェクトを生成
    specfic_datetime = datetime.datetime.strptime("2023-03-02 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid1 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid1 = " + str(ulid1))

    specfic_datetime = datetime.datetime.strptime("2023-03-03 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid2 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid2 = " + str(ulid2))

    specfic_datetime = datetime.datetime.strptime("2023-03-04 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid3 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid3 = " + str(ulid3))

    specfic_datetime = datetime.datetime.strptime("2023-03-05 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid4 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid4 = " + str(ulid4))

    # 整列した順番のリストを作成
    expected_list = list()
    expected_list.append(ulid1)
    expected_list.append(ulid2)
    expected_list.append(ulid3)
    expected_list.append(ulid4)

    # 順番をばらばらにしたリストを作成
    list_ulid = list()
    list_ulid.append(ulid2)
    list_ulid.append(ulid4)
    list_ulid.append(ulid1)
    list_ulid.append(ulid3)

    # 整列した順番のリストとばらばらなリストを比較
    print("Before = " + str(list_ulid))
    print("Is Same = " + str(expected_list == list_ulid))

    # ソート後にソートしたリストと整列した順番のリストを比較
    sorted_list = sorted(list_ulid)
    print("After  = " + str(sorted_list))
    print("Is Same = " + str(expected_list == sorted_list))

実行結果を確認すると、ソートできていることがわかる。

ulid1 = 0186a37b-1d00-223a-1718-56350b798aec
ulid2 = 0186a8a1-7900-b053-3768-799da20ec1a3
ulid3 = 0186adc7-d500-9872-0ecd-d7341e5b680a
ulid4 = 0186b2ee-3100-7041-5e91-7f7813f8d84d
Before = [UUID('0186a8a1-7900-b053-3768-799da20ec1a3'), UUID('0186b2ee-3100-7041-5e91-7f7813f8d84d'), UUID('0186a37b-1d00-223a-1718-56350b798aec'), UUID('0186adc7-d500-9872-0ecd-d7341e5b680a')]
Is Same = False
After  = [UUID('0186a37b-1d00-223a-1718-56350b798aec'), UUID('0186a8a1-7900-b053-3768-799da20ec1a3'), UUID('0186adc7-d500-9872-0ecd-d7341e5b680a'), UUID('0186b2ee-3100-7041-5e91-7f7813f8d84d')]
Is Same = True

uuidモジュールで生成したuuidをULIDオブジェクトに変換できる?

from_uuidクラスメソッドを使用すれば変換できる。 ただし、日時は異常な日時になる。

ulid2 = ULID.from_uuid(uuid.uuid4())
print("ULID from uuid = " + str(ulid2))
print(ulid2.datetime)
ULID from uuid = 1S57355C1B940S5RB1VZD2P2FW
3961-09-08 21:15:50.186996+00:00

PostgreSQLのorder by でソート

ULIDで生成したUUIDをPostgreSQLのテーブルに挿入し、OrderByでソートしても期待する順になるか確認してみた。 先ほどのリストのときと同じように、日時をずらしたULIDオブジェクトを生成、ULIDから生成したUUIDをばらばらに挿入。

from ulid import ULID
import datetime
import uuid
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy import select
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import uuid

class Base(DeclarativeBase):
    pass

class UUID_Sample(Base):
    __tablename__ = 'uuid_sample'

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True)

# DBエンジンを作成
url = "postgresql://postgres:example@localhost:5432/postgres"
engine = create_engine(url, echo=False)

# テーブルをDBに作成
Base.metadata.create_all(engine)
SessionClass = sessionmaker(engine)
session = SessionClass()

def use_db():
    
    # 日時指定で1日ずらして、ULIDオブジェクトを生成
    specfic_datetime = datetime.datetime.strptime("2023-03-02 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid1 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid1 = " + str(ulid1))
    ulid1_obj = UUID_Sample(id=ulid1)

    specfic_datetime = datetime.datetime.strptime("2023-03-03 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid2 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid2 = " + str(ulid2))
    ulid2_obj = UUID_Sample(id=ulid2)

    specfic_datetime = datetime.datetime.strptime("2023-03-04 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid3 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid3 = " + str(ulid3))
    ulid3_obj = UUID_Sample(id=ulid3)

    specfic_datetime = datetime.datetime.strptime("2023-03-05 18:00:00 +0000", "%Y-%m-%d %H:%M:%S %z")
    ulid4 = ULID.from_datetime(specfic_datetime).to_uuid()
    print("ulid4 = " + str(ulid4))
    ulid4_obj = UUID_Sample(id=ulid4)
    session.add_all([ulid2_obj, ulid4_obj, ulid1_obj, ulid3_obj])
    session.commit()

    # 整列した順番のリストを作成
    expected_list = list()
    expected_list.append(ulid1)
    expected_list.append(ulid2)
    expected_list.append(ulid3)
    expected_list.append(ulid4)

    stmt = select(UUID_Sample)
    results = session.scalars(stmt)
    print("Not use order by")
    i = 0
    for result in results:
        print("id = " + str(result.id))
        print("Is Sample = " + str(result.id == expected_list[i]))
        i += 1

    print("Use order by")
    stmt = select(UUID_Sample).order_by(UUID_Sample.id)
    results = session.scalars(stmt)
    i = 0
    for result in results:
        print("id = " + str(result.id))
        print("Is Sample = " + str(result.id == expected_list[i]))
        i += 1

結果はOrder byでソートされていることを確認できた。

ulid1 = 0186a37b-1d00-20ba-f545-49810830ce9d
ulid2 = 0186a8a1-7900-d435-b373-beeca474a2ed
ulid3 = 0186adc7-d500-26fa-2d95-ee12269bf390
ulid4 = 0186b2ee-3100-9cce-b5d3-4ab2c0ca769d
Not use order by
id = 0186a8a1-7900-d435-b373-beeca474a2ed
id = 0186b2ee-3100-9cce-b5d3-4ab2c0ca769d
id = 0186a37b-1d00-20ba-f545-49810830ce9d
id = 0186adc7-d500-26fa-2d95-ee12269bf390
Use order by
id = 0186a37b-1d00-20ba-f545-49810830ce9d
id = 0186a8a1-7900-d435-b373-beeca474a2ed
id = 0186adc7-d500-26fa-2d95-ee12269bf390
id = 0186b2ee-3100-9cce-b5d3-4ab2c0ca769d

最後に

UUIDは一意な値であるが、ソートができない課題を持っている。
ULIDがその課題を解決してくれている。
ULIDで生成したUUIDであればソート可能な値となっているので、テーブルのINDEXはUUIDにだけ設定しておけば不要なINDEXが増えなくてすむのかなと考える。
非常に便利なので、積極的に使っていきたい。

参考

ULIDに関するライブラリが多いとのこと。 今回使用したpython-ulidはメンテされている。

yu00sasaki.com

qiita.com

同一ミリ秒内での生成でも、同じミリ秒内に280 を超えない限り、ソート順が保証されるみたいで、良さそうです。

ここ重要ですね。同一ミリ秒で生成されることはないだろうかと。