flint>flint blog>2014年> 3月> 4日>IDシーケンスの管理

IDシーケンスの管理

データベースを利用したシステムでは、各レコードにそれを一意に識別するための識別子 (ID) を付与する必要があります。 多くの場合、この識別子はレコードがデータベースに登録された順に連番で割り振られるわけですが、こうした連番での識別子発行は、PostgreSQL では "SERIAL" を、MySQL であれば "AUTO_INCREMENT" を利用して実装するのが一般的な手法でしょう。

ところが、こうしたデータベース管理システムによって提供される機能を利用できない (したくない) 場合もあります。 当ブログシステム「鵞鳥の羽根ペン」もそのひとつ。 このシステムは複数のブログを管理するように作られているのですが、スケーリングなどの将来的な拡張を見越して、エントリやコメントなどのレコード識別子はブログごとに独立したシーケンスになるように設計されています。 例えば、テーブル "entry" は以下のように定義 (一部) されています:

カラム 制約 説明
blog INTEGER PRIMARY KEY ブログ識別子 (blog.id)
id INTEGER エントリ識別子
title TEXT NOT NULL 表題
content TEXT - 本文
author INTEGER NOT NULL 投稿者 (user.id)
time_post TIMESTAMP NOT NULL 投稿日時

そのため、同じデータベース内であっても、異なるブログに属していれば、複数のエントリに同一の識別子値が割り当てられることになります。 より正確に表現するならば、システムはブログ識別子 (blog.id) とエントリ識別子 (entry .id) の組 (ペア) を「完全な」識別子として個々の entry レコードを識別している、となるわけですが、いずれにしても、SERIAL や AUTO_INCREMENT によるテーブル単位での連番付加では、こうした運用には対応できません。

当初の設計

ではどのようにするかと云えば、話は簡単。 エントリ, コメントといった各種のレコードについて、ブログごとに、「識別子空間をどこまで使ったか」という情報を記録しておけば良いのです。 そこで、その機能を実装した当時 (2007年頃) の私は、テーブル "idseq" をこのようにを定義 (一部) しました。

idseq
カラム 制約 説明
blog INTEGER PRIMARY KEY ブログ識別子 (blog.id)
entry INTEGER NOT NULL 前回発行した識別子の値 (エントリ)
comment INTEGER NOT NULL 前回発行した識別子の値 (コメント)

そして、新しくブログが作成される (テーブル "blog" にレコードが追加される) ごとに、"idseq" にもに対応するシーケンス情報が追加されるようにしていました。 例えば、識別子として 4 を割り当てられたブログの追加時には、次のようなSQLを発行するわけです:

INSERT INTO "idseq" ("blog", "entry", "comment") VALUES (4, 0, 0);

この "idseq" テーブルにアクセスして、新規レコードの識別子を発行する手順は、コメント (テーブル "comment") に対してであれば、以下のようになるでしょう:

class Comment

    # [DB] 新規IDの発行
    def self.db_new_id(dbh, blog)
        check_type(DBI::DatabaseHandle, dbh,  false)
        check_type(Integer,             blog, false)

        row =dbh.select_one('SELECT "comment" FROM "idseq" WHERE "blog" = ?;', blog)
        return nil unless row

        id_prev =row[:comment]
        return nil unless id_prev

        id_new =id_prev + 1

        dbh.do('UPDATE "idseq" SET "comment" = ? WHERE "blog" = ?;', id_new, blog)

        return id_new
    end
end # class Comment

当時はすべてのテーブル、つい先日までは一部のテーブルに対して、識別子はこの方式によって発行されていました。

リファクタリング

ところが昨年、私は「このやり方はあまりスマートではない」と考え始めました。

この設計では、機能追加などによってデータベースに新しいテーブルが作られるたびに、"idseq" にもカラムの追加、即ち、定義の変更が必要になってしまいます。 また、それに合わせて、前節に掲載したような識別子発行のためのメソッド、そしてその内部で使用されるSQL文を書き足さなければなりません。

対象とするカラムの名前を指定することで、SQLを動的に生成するようにすれば、メソッドを共通化することも可能ですが、操作対象となるカラムがパラメタとなるため、「準備された文 (prepared statement)」 などの安全・高速化のための仕組みとの親和性の低い、「お行儀の悪い」実装になってしまいます。

そこで、新しくテーブル "idseq2" を以下のように定義することに:

idseq2
カラム 制約 説明
blog INTEGER PRIMARY KEY ブログ識別子 (blog.id)
table TEXT テーブル名
(例: 'entry', 'comment')
prev INTEGER NOT NULL 前回発行した識別子の値

このようにすれば、SQLのパラメタ (ブログ識別子, テーブル名) がすべて「値」である「お行儀の良い」実装ができるようになります。

class IDSeq

    # [DB] 新規IDの発行
    def self.db_new_id(dbh, blog, table)
        check_type(DBI::DatabaseHandle, dbh,   false)
        check_type(Integer,             blog,  false)
        check_type(String,              table, false)


        row =dbh.select_one('SELECT * FROM "idseq2" WHERE "blog" = ? AND "table" = ?; ', blog, table)
        return nil unless row

        id_prev =row[:prev]
        return nil unless id_prev

        id_new =id_prev + 1

        dbh.do('UPDATE "idseq2" SET "prev" = ? WHERE "blog" = ? AND "table" = ?;', id_new, blog, table)

        return id_new
    end
end # class IDSeq

より良い形を求めて

「鵞鳥の羽根ペン」のデータベースには、ここで例に出したエントリやコメントの他にも、タグ, ユーザ, セッション, アクセス等々、ブログごとに連番で発行される識別子を持つテーブルが数多く存在しています。 そのため、暇 (とやる気) のあるときに少しずつ移行作業を行っていたのですが、先日ついにすべてのテーブルについて移行が完了し、旧形式の "idseq" を削除 (ついでに、仮に "idseq2" としていた新形式のテーブルを "idseq" に変更) することができました。

DROP TABLE "idseq";
ALTER TABLE "idseq2" RENAME TO "idseq";

また、Entry, Comment といった各クラスにそれぞれ記述されていた識別子発行のロジックが、IDSeq のクラスメソッドに統一されることでコードの分量が減り、可読性・保守性も向上しました。

この「バッサリ削る快感」があるので、リファクタリングは止められません。

成田 (コード・チョッパ)
このエントリーをはてなブックマークに追加

コメント

投稿者
URI
メールアドレス
表題
本文