コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
MVCC(マルチバージョン同時実行制御)
集計を流したら、書き込みが止まった
売上テーブルを頭から読む集計クエリを、業務時間中に流したとします。数十秒後、注文の登録が次々とタイムアウトし始めました。集計は読んでいるだけで、1 行も書き換えていません。
読むだけでも止まるのは、読み手と書き手が同じ順番待ちの列に並ぶ作りだからです。読む側は「読んでいる間に書き換えられては困る」ので行を押さえ、書く側は「書いている途中を読まれては困る」ので行を押さえます。どちらも相手を待たせるので、長く読む処理が 1 本あるだけで書き込みが渋滞します。
古い値を捨てなければ、どちらも待たなくてよい
止まる理由は、1 行につき値が 1 つしかないことです。書き換えたら前の値が消えるので、読み手を待たせるしかありません。
そこで、更新のときに古い値を消さず、新しい値を別に足します。それぞれの版には「いつ作られたか」と「いつ消されたか」に相当する印が付いていて、読み手は自分が始めた時点を基準に、そのとき生きていた版を選びます。書き手は新しい版を足すだけなので、読み手を待たせません。読み手も、自分の版が残っている限り待ちません。これがマルチバージョン同時実行制御 (MVCC) です。
プレーンテキスト
時刻 10 T1 が開始
時刻 11 T2 が残高を 5000 から 8000 に更新して COMMIT
時刻 12 T1 が残高を読む -> 5000(T1 が始まった時点の版)
時刻 13 T3 が開始して読む -> 8000前の回で Repeatable Read を「自分が始めた時点の景色が固定される」と書きましたが、その固定はこの仕組みで実現されています。どの版を選ぶかはトランザクション単位で決まるので、読むだけの処理でも BEGIN で括る意味があります。括らずに 3 本の SELECT を投げれば、3 本がそれぞれ別の時点の景色を見ることになります。
ただし待たなくてよいのは読みだけです。同じ行を 2 本が更新しようとすれば、後から来た方は待たされます。読んだ値をもとに書き換える処理で、先に順番を押さえておきたいときは SELECT ... FOR UPDATE を使います。
古い版は、勝手には消えない
代償は、消したはずのデータがディスクに残り続けることです。どの版も、まだ読んでいるトランザクションがいるかもしれないので、その場では捨てられません。
回収は後片付けの処理が担当します。PostgreSQL では VACUUM、MySQL では取り消し用ログの整理がそれにあたります。問題は、開きっぱなしのトランザクションが 1 本でもあると、その開始時点より新しい版を捨てられないことです。
朝から放置された接続が 1 本あるだけで、その日 1 日ぶんの古い版が丸ごと残ります。テーブルの実サイズが行数の何倍にも膨らみ、読み取りまで遅くなります。「トランザクションは短く」が繰り返し言われるのは、待たせないためだけではありません。
犯人は探せば見つかります。PostgreSQL なら pg_stat_activity で、開いたまま idle in transaction で止まっている接続を確認します。多くは、アプリが例外で抜けたときに COMMIT も ROLLBACK も通らず、接続だけが返却されずに残っているケースです。