フラットファイル + シェルスクリプト「で」トランザクション

image_printPrintable Format

ファイルシステム「の」トランザクションではなく,データリポジトリにフラットファイルを使用して,ビジネスアプリケーションをシェルスクリプトで組むときにどうやって「トランザクション」を実現するかという話.

トランザクションは「データ(のセット)を(業務上)整合性のとれた一貫性のある状態に保ちながら更新すること」と言って良いと思う.これを実現するために,「コミット」や「ロールバック」の手段を整備することが必要になる.

SQL の場合,こんな感じにできるらしい.

BEGIN;

UPDATE accounts SET balance = balance + 10000 WHERE name = 'Jiro';

UPDATE accounts SET balance = balance - 10000 WHERE name = 'Taro';

COMMIT;

二つ目の UPDATE 文で,balance に「0または自然数」制約みたいなものが付いていて,なおかつ Taro の口座に 10,000円入っていなかった場合は,二つ目の UPDATE は失敗する.このとき DBMS は勝手に BEGIN の時の状態にデータを戻してくれる – Jiro の口座への 10,000円追加を無かったことにしてくれる(ロールバック).

これに加えて上記の SQL コードには陽に現れていないが,DBMS ではこのトランザクションが開始されると他のプログラムから Taro や Jiro の口座データが更新することはできない仕組みになっている(排他制御).同等のことを,フラットファイル + シェルスクリプトでどれだけシステマティックに実現できるかがテーマである.

排他制御をどう実現するか

ファイルアクセスの排他制御というと「ファイルロック」だが,UNIX では「アドバイザリロック」を使用するのが一般的だ.「マンダトリ(強制)ロック」がサポートされる OS もあるようだ(この辺のことはこちらを参照)が,ここではアドバイザリロックを前提にする.アドバイザリロックというのはあくまで「アドバイザリ」なので,カーネルがプログラムのファイルアクセスを禁止しているわけではない.従って,排他が問題になるプログラム群は,共通の排他制御のルールに従う(簡単に言うと,ファイルアクセスの際にいちいちロックの有無を確認→ロックする→アクセスする→ロックを解除する)ことが要求される.

アドバイザリロックを実現するにも何通りかのやり方がある.字面上いちばん名が通っているのが flock(2) である.ただ flock(2) は C プログラマ向けに用意された API であり,シェルスクリプトから利用するにはなんらかのパッケージを介在させる必要がある.Linux の場合は flock(1) コマンドが用意されているが,他の UNIX OS では一般に利用できない.また flock(1) も flock(2) も NFS に対応していないという仕様上の制限もある.

そういったことから,歴史的に「ロックファイル」の方式がよく使われる.これは,一つのファイルシステム上にパスが同じファイルは複数存在できないという仕様を利用しており,「ファイルAをロックする」ということを「(ファイルAに対応した)ロックファイルaを作成する」ということに読み替えてアドバイザリロックを擬似的に実現するものである.「ロックファイルaの作成を試みたが,できなかった」ということは「ファイルAは他のプログラムで使用されている」ということなので,プログラムはファイルAへのアクセスをいったん諦めて,時間をおいて再挑戦するなり,エラー終了するなりという挙動をするようプログラマがロジックを組む責任がある.

ところで「ファイルロック」を実現するのに「ロックファイル」を使用する,というのが用語上混乱をきたすことがあるかもしれない(自分がそうだったので).「セマフォファイル」という呼称にするとその心配がなくなるかもしれないが,本来セマフォは共有資源にアクセスできるプログラムの数を制限するためのものなので,排他制御の文脈で使うと(共有資源同時アクセス数 1 とすれば排他制御にもなるが)それはそれで混乱するかもしれない.

コミットをどう実現するか

フラットファイル + シェルスクリプトにおいて,データの更新方法はいくつかある.ここではフラットファイルがレコード(行)xフィールド(列)の二次元形式を取っていると想定すると,

レコード追記型更新

new_record="$(<処理>)"
printf "%s\n" "$new_record">> <フラットファイル>

非追記型(ランダム更新 – レコード挿入,削除,フィールド値更新)

<処理> < <フラットファイル> > <一時ファイル>
mv <一時ファイル> <フラットファイル>

こんなところだろう.ビジネスアプリケーションにおいては,レコード追記型更新で全てを済ませられる場合は少ないだろうから,ここは非追記型を想定する.非追記型はレコード追記型にも使用することができる.また,非追記型の更新は「更新試行」→「確定」というトランザクションのコミットの動きそのものである.あとは,トランザクションに関係する複数ファイルを「全て mv で更新する・しない」,言い方を変えると,複数ファイルのうち一部だけ mv で更新されてしまわないようにする制御を加えれば,トランザクションのコミットは実現可能ということになる.

なお mv による更新の魅力は,一時ファイルと本番ファイルが同一ファイルシステムに格納されている場合,それらを置き換えるにあたって I/O が発生しないことである(ファイルシステムメタデータの更新のため微量の I/O は発生するが,データを複製するための I/O は発生しない).この場合,一時ファイルによるフラットファイルの置き換えは一瞬で済ませることができる.

ロールバックをどう実現するか

コミットの考察で,非追記型更新を前提にすると述べた.この場合,ロールバックとは何かというと「mv で一時ファイルをフラットファイルに置き換えないままプログラムを終了すること」で良かろう.ただし単一ファイルの更新ならそれで良いのだが,複数ファイルの更新を行うトランザクションでは少々他の考慮も必要である.

2つのファイルAとBを更新するトランザクションがあったとする.AとBにロックを施して排他を宣言し,ファイルAとファイルBをそれぞれ更新していったん一時ファイルAと一時ファイルBを作成までできた(ここまででエラーが発生した場合は,一時ファイルAもBもない,や,一時ファイルAだけ生成されているという状況だから,そのままプログラムを終了すればファイルAとBの内容は整合性がとれたままである).

問題はこの後で,次は

  • 一時ファイルAをファイルA に mv
  • 一時ファイルBをファイルB に mv

するわけだが,前者が成功して後者が失敗した場合は,ファイルAを更新前の状態に戻さなければならない.ここが工夫のしどころとなる.

ファイルAの更新前の状態を保存するためにまず思いつくのは,トランザクション開始時に cp でバックアップを取っておくことである.

cp ファイルA ファイルA.org

そうして,一時ファイルBをファイルBに mv する操作が失敗したら,

mv ファイルA.org ファイルA

これでロールバックができた,ということになる(この操作が失敗したらどうするかというのは考慮事項だが,それはアプリケーションの要件によって変わってくるのでここでは議論しない.ロールバックが失敗したという通知をさせ,またはログを吐かせ,いったんシステムを停止する – ユーザーからのアクセスを遮断して管理者だけが操作可能にする – というのが定石だとは思う)

問題は,cp によるバックアップでデータ I/O が発生してしまうため,トランザクション開始時のオーバーヘッドが大きいということである.ファイルが数GBあった場合,トランザクション開始操作に数10秒を要することも想定される(RAM を大量に搭載していてファイルシステムキャッシュが潤沢に使えている場合は一瞬で完了する可能性はある – が毎回それは期待できない).

これを解決するのがハードリンクである.大学で共用計算機の SunOS を利用する際に「リンク」は基本「シンボリックリンク」を使え -「ハードリンク」は使ってはならぬと教え込まれたので,その理由もよく考えないまま(今から考えると,複数のファイルシステムにホームディレクトリが収容されていたからとかそういうことなんでは,と思うが)ハードリンクには馴染みのないままこれまで過ごしてきたが,cp の代替手段としてこのハードリンクが使える.

ln ファイルA ファイルA.org

これで「ファイルA」のデータは「ファイルA.org」という名前でも参照できるようになった.内部ではファイルシステム上で同じデータに違うラベルが追加された,ということなので,データ I/O は発生しない.この操作をおこなった後,「一時ファイルA」を「ファイルA」に mv して更新すると,「ファイルA」を指すと更新されたデータが得られるが,「ファイルA.org」を指すと更新前のデータが得られる.

従って,この後「一時ファイルB」を「ファイルB」に mv して更新する操作が失敗した場合,mv ファイルA.org ファイルA でファイルAを元の内容に戻すことが可能である.

定式(決め事)化

トランザクションでのデータ更新,コミット,ロールバックをどう実現するかの骨子は以上である.あとは以上の事柄をプログラマにできるだけ簡単になおかつ確実に実行してもらうための仕組み(決め事)作りである.

現在のところ,以下のような決め事とし,それをサポートする関数 INIT(), BEGIN(), COMMIT(), ROLLBACK(), END() を実装している(関数やファイル名の字面は実際とは異なる)

  • INIT() でトランザクションIDを設定して,本トランザクションで一時ファイル等を置く一時ディレクトリを作成
  • files-to-protect(トランザクション中,他のプログラムからのアクセスを遮断しなければいけないファイル群)を定義
  • files-to-update (トランザクション中更新するファイルと,更新一時ファイル名)を定義
  • BEGIN() で排他ファイルロックとハードリンク作成によるバックアップを実施
  • トランザクション処理はサブシェル内に記述し,エラーが発生すれば non-zero exit させる
  • non-zero exit (異常終了)した場合は ROLLBACK() する(ただし,この場合の ROLLBACK() は実質ほぼ何もしない – バックアップハードリンクを消すだけである)
  • zero exit (正常終了)した場合は COMMIT() するが.ここで失敗すると ROLLBACK() する
  • END() で排他ファイルロックを解除,バックアップハードリンク消去,トランザクション用一時ディレクトリを削除する
# トランザクションの初期化
INIT || exit 1

# このトランザクション中,排他アクセスが必要なファイルをリスト
cat <<EOF > files-to-protect || exit 1
ledger-Taro
ledger-Jiro
EOF

# このトランザクション中,更新するファイルとその一時ファイル名をリスト
cat <<EOF > files-to-update || exit 1
ledger-Taro    ledger-Taro.new
ledger-Jiro    ledger-Jiro.new
EOF

# トランザクション開始
BEGIN || exit 1

dt="$(date "+%Y/%m/%d %H:%M")" || exit 1
name=フリコミ
amount=10000

# トランザクション処理(事前に名前を決めておいた一時ファイルに更新データを出力する)
(
  # Taro の通帳更新
  printf "%s\t%s\t%d\n" "$dt" "$name" "-$amount" > /tmp/transaction || exit 1
  cat ledger-Taro /tmp/transaction > ledger-Taro.new || exit 1

  # Jiro の通帳更新
  printf "%s\t%s\t%d\n" "$dt" "$name" "+$amount" > /tmp/transaction || exit 1
  cat ledger-Jiro /tmp/transaction > ledger-Jiro.new || exit 1
)
es=$?

if [ "$es" -ne 0 ] ; then
  # トランザクション処理が失敗した場合 ROLLBACK() が一時ファイルを消去する
  ROLLBACK || exit 1
else
  # トランザクション処理が成功した場合 COMMIT() が一時ファイルを本番ファイルに mv する
  COMMIT || {
    # コミットに失敗した場合 ROLLBACK() がバックアップハードリンクから本番ファイルをリストアする
    ROLLBACK || exit 1
  }
fi

# トランザクション終了(END() がファイルロック解除,バックアップハードリンク消去を行う)
END || exit 1

フラットファイル + シェルスクリプト「で」トランザクション」への2件のフィードバック

  1. それで複数のファイルをmvした時に、一部のファイルだけmvしないようにするにはどうしたらいいのでしょうか?まさか一回のmv呼び出しならファイルのリネームはアトミックに行われるされると思ってませんよね?またロールバックする時に一部のファイルだけmvしないようにするにはどうしたらいいのでしょうか?複数のファイルをmvするとき、mvしている僅かな時間にマシンが落ちることもありますよね?

    BEGIN途中でエラーが発生するとかROLLBACKの途中でエラーが発生するとか、COMMITしてる途中で他の処理がBEGINして、ロックファイルを消したりするとか、COMMITが失敗してからROLLBACKする前にエラーが発生するとか穴がありまくりです。一瞬で処理されるからその間に処理が割り込まれることはないとかを前提にしてませんか?トランザクション処理を語るには極めて雑だと思います。

  2. コメントいただきありがとうございました.(ひょっとして,PPID の件でご教示をいただいた方と同じ方でしょうか?それでしたら重ねてありがとうございます)

    ご指摘いただいた件ですが,

    1) 「複数のファイルをmvした時に、一部のファイルだけmvしないようにする」
    このファイルの mv は COMMIT 時の mv ですが,COMMIT() の中で(シーケンシャルに複数回発生する mv – 一つの mv コマンドでは1ファイルのみ mv します – のうち)どれかが失敗したら ROLLBACK() します.ROLLBACK() ではバックアップ用ハードリンクの inode 番号を調べて本番ファイルと比較し,同じならまだ COMMIT によって mv されていないファイルですので何もせず,異なる場合は COMMIT によって mv されたファイルですのでバックアップ用ハードリンクから mv して戻します.この動作により「複数のファイルを mv したときに一部のファイルだけ mv されている」状況を発生させないようにします.ROLLBACK() 中でエラーが発生した場合は exit しますが,この後,どうやってシステムを復旧させるかは本考察ではカバーしていません.

    2) 「一回のmv呼び出しならファイルのリネームはアトミックに行われる」
    1ファイルを mv するかぎりはアトミックに行われると考えておりました.多分,私が(例えば)
    mv <ファイル1> <ファイル2> <ディレクトリ>
    がアトミックだと思っていると想像されたのではと思いますが,そうではありません.

    3) 「ロールバックする時に一部のファイルだけmvしないようにする」
    1) で説明できているつもりですが,もしまたこのコメントをご覧いただけて,私の考え違いにお気づきでしたら厚かましいですがご教示いただけると助かります.

    4) 「複数のファイルをmvするとき、mvしている僅かな時間にマシンが落ちることもある」
    おっしゃる通りです.1) で「ROLLBACK() 中でエラーが発生した場合は exit しますが,この後,どうやってシステムを復旧させるかは本考察ではカバーしていません.」と書いていますが,マシンが落ちてくれるのであれば,再起動時にサービス開始前に仕掛かりトランザクションの情報を参照し,仕掛かりトランザクションがあれば管理者介入を促して対策が取られるまでサービス開始しない,等の造り込みに頼るかと考えています.

    5) 「BEGIN途中でエラーが発生する」
    BEGIN() では a) ロックファイル作成によるファイルロック,b) バックアップ用ハードリンク作成 を行いますが,これらが失敗した場合,例では exit 1 と記していますが,ERROR_EXIT 関数を呼んでその中で自身が作成したロックファイルの消去を行ってロックを解除します.

    6) 「ROLLBACKの途中でエラーが発生する」
    本考察ではカバーしていません.4) での考察を深化させる必要があります.

    7) 「COMMITしてる途中で他の処理がBEGINして、ロックファイルを消したりする」
    ここで定めた方式に従わないプログラムや運用者がロックファイルを消すことはあり得ます(これはどんな仕組みでも避けられないと思います)が,他の処理で呼ばれた BEGIN() がロックファイルを消すことはありません.他の場面でロックファイルを消す場合,RELEASE_LOCKFILE 関数を使用しますが,この関数内ではロックファイルの中に格納した lockid と,自プロセスが行なった(まだ解放していない)ロックデータベースとの照合を行い,ロックデータベースにエントリがなければロックファイルの消去をしない仕組みにしています.

    8) 「COMMITが失敗してからROLLBACKする前にエラーが発生する」
    意図されていることが当方で認識できません.すみません.ROLLBACK におけるエラーではなく,COMMIT が  return してから ROLLBACK が呼ばれるまでの間に何かあるという事でしょうか.

    以上,ご指摘の点を拾ってみましたが,穴がありまくりというご指摘ですので上記8点はあくまでそのうち目に余る例を挙げていただいたのかと思います.
    ご厚意をいただけるようでしたら,引き続きコメントを頂戴できると幸いです.

    今後ともどうぞよろしくお願いいたします.

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です