Featured image of post コード探偵ロックの事件簿【Object Pool】使い捨ての代償〜借りて返す資源管理術〜

コード探偵ロックの事件簿【Object Pool】使い捨ての代償〜借りて返す資源管理術〜

Object PoolのcheckoutとcheckinプロトコルでDB接続を安全に再利用し、Object Cesspoolによるトランザクション漏れを構造的に防止する

現場検証 — 隔壁の中の異変

木曜の午前10時。わたしのオフィスは普段通りの空気だった。

チームの4人中3人が出社していて、隣のデスクの小川くんはイヤホンをしてコードを書いている。わたしはGrafanaのダッシュボードを開いて、注文APIのp99レスポンスタイムのグラフを見ていた。8時台のピークに350msの山が立っている。通常は80ms前後。3週間前にBulkheadを導入してから、月末バッチの暴走による全館停電は完全に止まった。それは成果だ。でも、代わりに別の異変が始まっていた。

メールアプリを開く。3日前にロックさんへ送ったメールの返信が来ていた。

木曜の午前、現場検証に向かう。住所を送りたまえ。

……現場検証って、うちの会社に来るということだろうか。

10時ちょうど。オフィスの入口から、ツイードジャケットに蝶ネクタイの男が入ってきた。片手に木製の帆船模型を抱え、もう片手にビニール袋を下げている。受付で一瞬だけ立ち止まったが、すぐにわたしたちのエリアに向かって歩いてくる。迷いのない足取り。

小川くんがイヤホンを外してこちらを見た。わたしは小さく首を振った。

「ワトソン君。現場検証は現場で行うものだ」

ロックさんはわたしの前に立つと、帆船模型をデスクの端に置いた。前回LCIの事務所で組み立てていた模型だ。帆が張られ、甲板にミニチュアの舵輪が付いている。完成している。

「……ロックさん。受付は通りましたか」

ロックさんは空いている椅子を引き寄せて座った。ビニール袋からエナジードリンクを2本出し、1本をわたしのデスクに置く。もう1本を自分で開けた。

「通行証は不要だった。入口の人間に"外部セキュリティ監査"と伝えたら通してくれた」

セキュリティ監査。嘘ではないけど、正確でもない。

小川くんが小声で聞いてきた。「……あの人、誰?」

「外部のコンサルタントです」

小川くんは帆船模型を見て首を傾げたが、それ以上は聞かなかった。

わたしはモニタの方を向き直った。

「Bulkheadの件、うまくいきました。月末のバッチ暴走、完全に止まっています。……ただ」

ロックさんは帆船模型の甲板を指先で軽くなぞっていた。関心は帆ではなく船体にあるようだった。

「ただ?」

Grafanaのグラフを指した。

「注文APIのp99が350msに跳ねるピークが出始めました。ピーク帯の秒間30リクエストのときだけ。調べたら、リクエストごとにDBI->connectして$dbh->disconnectしていました。接続確立のコストが積もっています」

ロックさんはモニタを見ていた。p99のグラフではなく、その下のDB接続数の推移グラフに目を止めている。

「……接続数が波を打っている。リクエストのたびに上がり、すぐ下がる。——ワトソン君、この接続は使い捨てか」

「はい。1リクエストに1接続。使ったら捨てています」

ロックさんがデスクの端の帆船模型を軽く叩いた。

「隔壁は立てた。部屋は分かれた。だが部屋の中で、バケツの水を毎回汲んでは捨てている。……水道管を引く方が合理的だとは思わなかったか」

「思いました。だから、直しました」

わたしはそこで一拍置いた。

「……そこで問題が起きたんです」

汚染された接続 — Object Cesspool の発見

エディタを開いた。自分が書いたコードのファイル。指がキーボードの上で一瞬止まった——それからファイルを開く。

「接続をキャッシュするようにしました。同じプール名なら前の接続を返す。……1週間は動きました。2週目に在庫データが壊れ始めました」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package BulkheadPool;
use v5.36;
use Moo;
use Types::Standard qw(HashRef Str);

has dsn  => (is => 'ro', isa => Str, required => 1);
has user => (is => 'ro', isa => Str, required => 1);
has pass => (is => 'ro', isa => Str, required => 1);

has _cache => (is => 'ro', isa => HashRef, default => sub { {} });

sub get_connection ($self, $pool_name) {
    my $cache = $self->_cache;

    # キャッシュにあれば再利用
    if (exists $cache->{$pool_name} && $cache->{$pool_name}->ping) {
        return $cache->{$pool_name};
    }

    # なければ新規作成してキャッシュ
    my $dbh = DBI->connect(
        $self->dsn, $self->user, $self->pass,
        { RaiseError => 1, AutoCommit => 1 },
    );
    $cache->{$pool_name} = $dbh;
    return $dbh;
}

pingで接続の生存確認はしています。切れていたら新しく作る。……問題はここじゃなくて」

ロックさんはコードを読んでいた。人差し指でモニタのreturn $cache->{$pool_name}の行を指す。

「返すときに、何をしている?」

「……何も」

「何もしていない。——ワトソン君、この接続が前のリクエストでBEGINしたままCOMMITしていなかったら?」

3秒、コードに目を戻した。

「……次のリクエストが、前のトランザクションの中で動く」

「そうだ。前の事件の証拠が、次の事件の現場に持ち込まれている。——これはObject Cesspool(汚染されたプール)だ

AutoCommit の罠

「AutoCommitは1にしてあるのに……なぜトランザクション状態が残るんですか?」

コードのAutoCommit => 1を指した。設定してある。にもかかわらずトランザクション漏れが起きた。

「AutoCommitはコネクション作成時の設定だ。だが、アプリケーションコードの中で明示的に$dbh->begin_workを呼んだらどうなる?」

「……AutoCommitが一時的にオフになる。commitrollbackを呼ぶまで」

「では、begin_workの後に例外が発生して、commitrollbackも呼ばれなかったら?」

注文処理のコードを思い出した。begin_workを使っている。在庫確保と注文レコード挿入をアトミックにするために。

「注文処理の中でbegin_workを使っています。例外が起きたらevalで捕まえてrollbackしているはずですが」

「“はず”。——コードを見せたまえ」

注文処理のファイルを開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sub process_order ($self, $order) {
    my $dbh = $self->pool->get_connection('order_api');

    $dbh->begin_work;

    my $stock = $dbh->selectrow_hashref(
        "SELECT quantity FROM inventory WHERE product_id = ?",
        undef, $order->{product_id},
    );

    # ★ 早期リターン: rollback を通らない
    return { error => 'out_of_stock' }
        if $stock->{quantity} < $order->{quantity};

    $dbh->do(
        "UPDATE inventory SET reserved = reserved + ? WHERE product_id = ?",
        undef, $order->{quantity}, $order->{product_id},
    );
    $dbh->do(
        "INSERT INTO orders (product_id, quantity, status) VALUES (?, ?, 'pending')",
        undef, $order->{product_id}, $order->{quantity},
    );

    $dbh->commit;
    return { success => 1 };
}

コードを追った。begin_work。在庫チェック。在庫不足なら——。

「……returnしている。dieじゃなくてreturnrollbackを通らない」

「そうだ。この接続がキャッシュに戻り、次のリクエストに渡される。トランザクションは開いたままだ。——使った道具を洗わずに棚に戻した。次に使う人は、汚れた道具を受け取る」

ロックさんはホワイトボードの前に立った。わたしが3週間前に描いたBulkheadの構成図——注文API:8本、検索API:8本、バッチ:4本の隔壁図がまだ残っている。その横に赤ペンで図を描き始めた。

1
2
3
4
5
6
7
[get_connection] → [begin_work] → [在庫チェック]
                                      ↓ (在庫切れ)
                                   [return ← ここで接続がキャッシュに戻る]
                                   [トランザクションは BEGIN のまま]
                                      ↓ (次のリクエスト)
                                   [get_connection → 同じ接続を取得]
                                   [前のBEGINがまだ生きている]

「……returnで関数を抜けても、接続は閉じない。キャッシュに残ったまま」

「そうだ。次にこの接続を受け取ったリクエストは、開いたトランザクションの中で動作する。前の事件の証拠品が、次の事件の現場に混入した——鑑識が汚染された証拠で容疑者を特定したら、冤罪になる」

わたしの描いたBulkheadの構成図の横に、ロックさんの赤い字が並んでいる。自分の空間にロックさんの痕跡が侵入してくるのは、前回のLCI事務所を訪ねたときとは違う感覚だった。

checkout と checkin — 借りて返す作法

ノートを開いた。前回のように「図にしていいですか?」とは聞かなかった。描き始めた。

get_connection → use → ???

???の部分が空白だ。

「わたしの実装にはgetしかない。返す概念がない。……つまり、返すときにリセットする仕組みが要る」

ロックさんがノートの???を指で叩いた。

「そうだ。Object Poolの核は2つの動作だ。checkout——有効性を確認してから貸し出す。checkin——状態をリセットしてからプールに戻す。君のコードにはcheckoutの半分——ping——はあったが、checkinが完全に欠けていた」

ノートに描き足した。

1
2
checkout: validate → return obj
checkin:  reset → push to pool

「そして重要なのは——リセットの責任はプール側にある。使う側がリセットを忘れても、プールがcheckin時に必ずリセットを実行する。さっきのbegin_workの抜け穴は、リセットをアプリケーションコード側に委ねていたから起きた」

「……プール側でやれば、使う側のコードパスに抜けがあっても安全。構造で保証する」

「そうだ。これはBulkheadと同じ原則だ——個々のコードの正しさに依存しない。構造で安全を保証する

3週間前にLCIの事務所で聞いた言葉と重なった。壁を立てるのは、個々のリクエストの行儀を信用しないため。プールのリセットも同じだ。個々の使い手の後始末を信用しない。

ロックさんはホワイトボードに戻り、赤ペンでConnectionPoolクラスの設計を描き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package ConnectionPool;
use v5.36;
use Moo;
use Types::Standard qw(Int CodeRef ArrayRef Num);
use Carp qw(croak);
use Time::HiRes qw(time);

has max_size => (is => 'ro', isa => Int, default => 10);
has min_size => (is => 'ro', isa => Int, default => 2);
has factory  => (is => 'ro', isa => CodeRef, required => 1);

# checkout 時の検証関数
has validator => (
    is      => 'ro',
    isa     => CodeRef,
    default => sub { sub ($obj) { 1 } },
);

# checkin 時のリセット関数(プール側の責務)
has resetter => (
    is      => 'ro',
    isa     => CodeRef,
    default => sub { sub ($obj) { } },
);

has checkout_timeout => (is => 'ro', isa => Num, default => 3.0);

has _available => (is => 'ro', isa => ArrayRef, default => sub { [] });
has _total     => (is => 'rwp', isa => Int, default => 0);

checkout — 検証付きの貸し出し

「checkoutの中を見たまえ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sub checkout ($self) {
    my $deadline = time() + $self->checkout_timeout;

    while (1) {
        # 1. プールから取り出して検証
        while (my $obj = pop $self->_available->@*) {
            if ($self->validator->($obj)) {
                return $obj;  # 有効ならそのまま返す
            }
            $self->_set__total($self->_total - 1);  # 無効なら破棄
        }

        # 2. 上限未満なら新規生成
        if ($self->_total < $self->max_size) {
            my $obj = $self->factory->();
            $self->_set__total($self->_total + 1);
            return $obj;
        }

        # 3. プール枯渇 → タイムアウトチェック
        croak "ConnectionPool: checkout timeout"
            if time() >= $deadline;

        select(undef, undef, undef, 0.05);
    }
}

「checkoutのとき、接続が切れていたらどうするんですか? pingするだけで十分ですか?」

pingは最低限の確認だ。“接続が生きているか”。だが、生きていても使えない場合がある」

「たとえば?」

「接続のアイドル時間が長すぎて、サーバー側でセッションが切れている場合。ファイアウォールが長時間アイドル接続を静かに切断する場合。pingはTCPレベルの応答を見るが、サーバー側のセッション状態は検証しない」

ノートに描きながら聞いた。

「じゃあ、checkoutでpingして、ダメなら破棄して新しく作る。……作れなかったら?」

「プールの上限に達していなければfactory(生成関数)で新しく作る。上限に達していれば——」

「待つか、断るか」

「速やかに断る方がいい。Bulkhead回で話したことを覚えているか?」

「……“隔壁の原則は速やかな失敗”。空きがなければ即座に断る」

「同じだ。プールが空なら、タイムアウト付きで短く待ち、それでも取れなければ即座にエラーを返す。待ち続ければ、リクエストがプールの手前で渋滞する——Bulkheadで防いだはずの問題が、プールの入口で再発する」

……2回目だとこの呼び方にも慣れるものだな。ワトソン君。ロックさんの発話パターンの一部として処理できるようになっていた。

checkin — リセット付きの返却

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
sub checkin ($self, $obj) {
    # ★ リセットはプール側の責務
    my $ok = eval { $self->resetter->($obj); 1 };
    if (!$ok) {
        # リセット失敗 → 接続を破棄
        $self->_set__total($self->_total - 1);
        return;
    }

    if (scalar($self->_available->@*) < $self->max_size) {
        push $self->_available->@*, $obj;
    } else {
        $self->_set__total($self->_total - 1);
    }
}

with_connection — スコープベースの自動返却

1
2
3
4
5
6
7
8
9
# スコープベースの自動返却(リーク防止)
sub with_connection ($self, $callback) {
    my $obj = $self->checkout;
    my $result = eval { $callback->($obj) };
    my $err = $@;
    $self->checkin($obj);
    die $err if $err;
    return $result;
}

ロックさんの設計と自分のコードの差分を確認した。

「わたしの実装との違い……3つあります」

「言ってみたまえ」

ノートに差分を書き出した。

「1つ目。checkinメソッドがある。返却時にresetterでリセットする。わたしの実装には返却の概念がなかった」

ロックさんがうなずいた。

「2つ目。with_connection。ブロックで囲って自動返却する。evalで例外を捕まえてからcheckinを呼ぶから、例外が起きてもリークしない。わたしのprocess_orderの早期return問題は、これで構造的に防げる」

「そうだ。使い手がリセットを忘れても、返却を忘れても、プールが保証する」

「3つ目。resetterの失敗時。evalで囲って、リセット自体が失敗した接続は破棄する。壊れた道具は棚に戻さない」

ロックさんの口角がわずかに上がった。前回の事務所で、わたしが即時拒否の問題を自分で指摘したときと同じ系統の、小さな反応。

「……3つ目に気づいたか。resetterの失敗処理は、見落とされやすい」

checkout/checkin の全体の流れを図にした。

	flowchart LR
    A[checkout] --> B{プールに空きあり?}
    B -->|Yes| C{validator 検証}
    C -->|有効| D[オブジェクトを返す]
    C -->|無効| E[破棄して再試行]
    B -->|No| F{上限未満?}
    F -->|Yes| G[factory で新規生成]
    G --> D
    F -->|No| H{タイムアウト?}
    H -->|Yes| I[例外を投げる]
    H -->|No| J[短い待機 → 再試行]
    J --> B

    K[checkin] --> L[resetter でリセット]
    L -->|成功| M[プールに戻す]
    L -->|失敗| N[破棄]

隔壁の中の水流 — プールの設計

「プールのサイズはBulkheadの割り当てと同じにすべきですか? 注文APIの隔壁が8接続なら、プールも8ですか?」

実装を週明けにチームに持ち帰ることを考えると、パラメータは具体的に詰めておきたかった。

「8は上限——同時に存在できる最大数だ。だが常に8本が必要か?」

「……通常時は秒間10リクエスト程度。接続は1リクエストあたり数ミリ秒で返ってくる。同時に4本もあれば十分です」

「ならばmin_sizeを2〜3、max_sizeを8にする。需要に応じて2から8の間でプールが伸縮する。——しかし、もう1つ重要なパラメータがある」

「アイドルタイムアウト?」

ロックさんが薄く目を見開いた。前回と同じ反応。

「そうだ。使われていない接続はいつまでもプールに居座るべきではない。サーバー側にも接続リソースのコストがある。idle_timeoutを設定し、一定時間使われなかった接続は静かに閉じる。min_size分だけは残す」

ホワイトボードの前に立った。ロックさんが赤ペンで描いた図の横に、自分のペンで追記した。Bulkheadの「8接続」枠の中に、プールのパラメータを書き込む。

「Bulkheadが壁。プールが壁の中の水位管理。……壁が"同時にいくつまで"で、プールが"どう回すか"」

「正しい。Bulkheadは量の隔離。Object Poolは質の管理。壁を立てるだけでは、中が腐る」

リファクタリング後の注文処理

DB接続プールの構築。アプリケーション初期化時に一度だけ行う。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
my $order_pool = ConnectionPool->new(
    max_size         => 8,   # Bulkhead の注文API割り当てと一致
    min_size         => 2,
    checkout_timeout => 3.0,
    factory          => sub {
        DBI->connect($dsn, $user, $pass, {
            RaiseError => 1,
            AutoCommit => 1,
        });
    },
    validator => sub ($dbh) {
        eval { $dbh->ping } ? 1 : 0;
    },
    resetter => sub ($dbh) {
        # ★ チェックイン時のリセット(プール側の責務)
        $dbh->rollback unless $dbh->{AutoCommit};
        $dbh->{AutoCommit} = 1;
    },
);

注文処理のリファクタリング後。with_connectionで自動checkout/checkin。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
sub process_order ($self, $order) {
    return $self->pool->with_connection(sub ($dbh) {
        $dbh->begin_work;

        my $stock = $dbh->selectrow_hashref(
            "SELECT quantity FROM inventory WHERE product_id = ?",
            undef, $order->{product_id},
        );

        if ($stock->{quantity} < $order->{quantity}) {
            $dbh->rollback;
            return { error => 'out_of_stock' };
        }

        $dbh->do(
            "UPDATE inventory SET reserved = reserved + ? WHERE product_id = ?",
            undef, $order->{quantity}, $order->{product_id},
        );
        $dbh->do(
            "INSERT INTO orders (product_id, quantity, status) VALUES (?, ?, ?)",
            undef, $order->{product_id}, $order->{quantity}, 'pending',
        );

        $dbh->commit;
        return { success => 1 };
    });
    # ← ブロックを抜けると自動的に checkin
    # ← 例外が起きても checkin → resetter が rollback を保証
}

ノートにBeforeとAfterを並べた。

with_connectionのブロックの中でもrollbackを書いていますが……ブロックを出た後のcheckinでもresetterrollbackする。二重ですか?」

「二重だ。——しかし、rollbackは冪等だ。開いたトランザクションがなければ何もしない。ブロック内のrollbackは"作法として正しいコード"。resetterのrollbackは"仕組みとしての安全網"。作法と仕組み、両方あって初めて安全だ」

「……ベルトとサスペンダー」

ロックさんが小さく笑った。

「そうだ。コードの世界にも冗長性は必要だ」

壁の中の作法

ロックさんが立ち上がった。ツイードジャケットの埃を払い、ホワイトボードの前で一瞬止まる。

わたしが3週間前に描いたBulkhead構成図——注文API:8本、検索API:8本、バッチ:4本の隔壁図がまだ残っている。ロックさんは赤ペンを取り、注文APIの隔壁の中に小さく描き足した。

checkout → use → checkin

「……壁の中に、もう1つ仕組みが要ったんですね」

ロックさんは赤ペンを置いた。帆船模型に目をやった——が、取り上げない。そのままわたしのデスクに残した。

「壁が守るのは"隣の部屋"だ。部屋の中を守るのは、別の仕事だ。——また異変があれば連絡したまえ、ワトソン君」

ロックさんがオフィスを出ていった。小川くんが「帰った?」と小声で聞いてきた。うなずいた。

ホワイトボードの前に立った。Bulkheadの構成図の中に、ロックさんの赤い字。checkout → use → checkin。その横に、自分のペンで(validate)(reset)を書き足した。

目が帆船模型に移った。完成した模型。帆を張り、甲板があり、内部に隔壁がある。ロックさんが忘れていったのか、置いていったのか。

……全部のコネクションを最初から作っておく必要あるのかな。必要になったときに作れば、起動時のコストは——。

思考を途中で止めた。それは今日の問題ではない。

帆船模型を片付けずに、そのまま置いておいた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
高コストオブジェクトの毎回生成・破棄Object Pool(プールによる再利用)p99レスポンス 350ms → 90ms
Object Cesspool(返却時リセット漏れ)checkin 時のリセットプロトコル在庫データ不整合ゼロ
リーク(例外時の返却忘れ)with_connection(スコープベース自動返却)接続リーク検出ゼロ

推理のステップ

  1. Bulkheadでプールを分離した後、各プール内部でDB接続を毎回connect/disconnectしていた(使い捨て)
  2. 接続確立コスト(TCPハンドシェイク+認証 ≒ 15ms/回)がピーク時に積み重なり、p99レスポンスが80ms→350msに劣化
  3. 応急策として接続をキャッシュ(_cacheハッシュ)したが、返却時のリセット処理が欠けていた
  4. begin_work後の早期returnで未コミットのトランザクション状態が次のリクエストに漏れた(Object Cesspool)
  5. Object Poolのcheckout/checkinプロトコルで「検証付き貸出」と「リセット付き返却」を構造化
  6. with_connectionパターンで例外時のリーク(返却忘れ)を構造的に防止
  7. プールサイズ(min_size/max_size)でBulkheadの割り当てと整合させ、idle_timeoutで不要な接続を回収

ロックより

壁を立てただけでは足りない。中のものを腐らせるなら壁を立てた意味がない。隔壁は隣室への延焼を止める。だがプールの中身——接続の状態、トランザクションの残骸、前の使用者の痕跡——を清掃する仕事は、隔壁の管轄外だ。

借りて、使って、返す。返すときに前の痕跡を消す。checkoutで検証し、checkinでリセットし、with_connectionでスコープを閉じる。この循環をプール側が保証する限り、個々の使い手の後始末に依存しなくて済む。鑑識の基本と同じだよ、ワトソン君——現場に入ったら、出るときに自分の痕跡を残すな

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。