でかパケット

ある機械のLAN通信が不安定と言うことで、ちょっとパケットを採取してみることになった。
いいよね、パケットキャプチャ。何かこう、自分みたいなエンジニアもどきが、いっちょ前のエンジニアっぽく見えるあたりが。

バカハブやミラーポート付のスイッチングハブが無かったので、取り敢えずデータを送っているPCに直接WireSharkを仕込んでパケットを取ってみた。

初めは普通に3ウェイハンドシェイクが行われて、さてデータ送信ってところで……なんじゃこりゃ。フレームサイズが13000Byteもあるんですが……?

あ、これってあれか。「ジャンボフレーム!」(ハイ、ムカミ!)

まあ、だとしたら、この機械がジャンボフレーム未対応製品なんじゃね?はい一件、らくちゃ……

……あれ、でも待てよ。スイッチやルータみたいな中間の機器類が対応していないってのなら分かるけど、TCPでは双方で受信可能なパケットサイズの確認をとってから、最大受信サイズの小さい方に合わせてデータ送るよなぁ。と思って、双方のSYNパケットを見てみたら、ちゃんとどっちもMSS=1460って記載されてる。

ってことは何か。PC側は、自分は1パケットのデータサイズ1460byteで送るよーって言っておきながら、その10倍近い大きさのデータを送りつけているということか。何て卑劣なPCだ。あれだ、マラソン大会で一緒に走ろうとか言っておいて、さっさと先に行っちまう輩レベルだ。

それはさておき、通信の挙動を見ていると、途中まではPCと機械は普通に通信しているのだけど、PCからの送信量が多いのか、機械の方がWindowSizeを14000バイト近くまでアップデートしている。その直後、PCからのフレームが13000バイトに膨れ上がるのだけれど……あれ、コレおかしくないか。
WindowSizeは受信バッファのことだから、まあ、理屈としては13000バイトのパケットも物理的には受け取れるのかも知れないけれど、本来WindowSizeというのは「WindowSizeで示したバイト数まで、複数のパケットをまとめて受け取れますよ」というだけの意味で、1パケットあたりのデータサイズそのものは、MSSに記載したバイト数であるべきじゃないのかしらん。5tトラックが20台入れる倉庫に、100tトラック(何それ)が1台突っ込んできたとして、果たしてその倉庫はそれを受け入れるだろうか。

ジャンボフレームに関しても調べてみたけど、やっぱりジャンボフレームを使用する際はMSSもそれに合わせて大きくなるらしい。てことは、MSS=1460で宣言しているこのPCは、ジャンボフレームを使用しているつもりは無い筈である。

Windowsは内部ルーティングを行うから、実際にLAN線上を走っているのはその時点でフラグメントされて1460Byteになったパケットなのかなぁと思ったけれど、フラグメント禁止フラグがちゃっかりONになってるしなぁ

念の為、別のPC(メーカー違うけど)にもWireSharkをインストールして、同じアプリケーションで通信したら、そちらはずっと1460Byteのまま通信を続けていた。
ってことは、LANカードかそのドライバの不具合なのかなぁ……。それとも、僕の知らない何かしらのルールが適用されているとか……あ〜……訳分かんない

トランザクションで遊んでみた

何かデッドロックの一件でトランザクション分離レベルの変更を行ったけれど、イマイチこの分離レベルの詳細が分からないので、実験してみた。誰ですか、暇人だなんて言う人は。

=======================================================
【実験環境】
Windows7(32bit)
・SQLServer2005Express

【実験方法】

  1. クラスタインデックスと非クラスタインデックスを持ったテーブルを用意する。データは1万件を用意。
  2. VBSにて、対象テーブルに対してトランザクション開始→全件読み取り→待機→全件更新→待機→コミット……というスクリプトを書く
  3. VBSを起動し、全件読み取り後の待機まで進める。
  4. ManagementStudioにて、同じテーブルに対しトランザクションを張り、SELECT、INSERT、DELETE、UPDATEをそれぞれ発行してどのような動作になるか確認する。
  5. VBSを起動し、全件更新後の待機まで進める。
  6. ManagementStudioにて、同じテーブルに対しトランザクションを張り、SELECT、INSERT、DELETE、UPDATEをそれぞれ発行してどのような動作になるか確認する。

========================================================

ってなことをやってみた結果がこちら

※注1の補足:読み取りは出来るが、トランザクションAが更新を行っていないかのように表示される(スナップショットを読む為)。その為、Read_UnCommittedとは読み取り結果が異なる。

トランザクションAがVBSの処理、
トランザクションBがManagementStudioの処理です。

こうして見ると、読み取りに関する挙動を勘違いしていたなぁ。例えば、Read_Uncommittedでトランザクションを行った場合に、他のトランザクションから未コミット分の更新データの読み取りが出来るもんだと思っていたんだけれど、データ更新側ではなくて、データ読み取り側のトランザクションでRead_Uncommittedにする必要があったのね。

今回は表にしやすくするためにテーブル全件を対象にしたけれど、当然ロック範囲をもっと絞り込めば、ロック範囲外では更新だの追加だの行えました。(Repeatable_Readの※注2は、そのデータがロック範囲外であるからでしょうし)

仮説仮説検証

てことで、昨日思い浮かんだ仮説を本来の業務中に時間をとって検証してみた。

【検証方法】
昨日の2つのVBSの中で、トランザクション分離レベルをSerializableにして、同時に起動してみた。

【結果】
現象再現した!\(^▽^)/

いや〜、SQLServerのエラーログがデッドロックだらけですわ……。こりゃ見てて爽快……ってか、DBがデッドしそう怖い。
てことは、下記の理屈でデッドロックしてたってことですかね

  1. トランザクションAとトランザクションBが、同時に起動する。この二つのトランザクションが影響するロック範囲には、共通する範囲が含まれる。
  2. トランザクション分離レベルがSerializableなのでトランザクションAの参照処理中、トランザクションBの終了を待つ
  3. 同様に、トランザクション分離レベルがSerializableなので、トランザクションBの参照処理中、トランザクションAの終了を待つ
  4. はい、デッドロックの(以下略)


ところでこのデッドロックは……サイクルデッドロックに分類されるのかしらん?


現象再現したところでプログラマに確認してみたところ、ビンゴ。もう現象は出ていないけど、今後のことを考えてRead_Committedに変えてもらった。ふぅ、やれやれ、これでやっと解決かなぁ。


【今回のきょ〜くん】
1:データベースには主キーをちゃんとつけよう。

  • 主キー(正確には、インデックス)をつけないと、テーブル全体が参照走査対象になる。
  • その為、無駄なパフォーマンスが発生するだけでなく、ロック範囲が肥大化する
  • その為、複数クライアントでデータ更新時にロック範囲が頻繁にかちあう。


2:トランザクション分離レベルは適切なものを使おう

  • 低いトランザクション分離レベルではダーティリードだとか、ファントムリードだとか、イメージの悪いデータ不整合が起きそうな言葉ばっかりついてまわるが、逆に高い分離レベルは高い分離レベルで、DB設計段階からロック範囲を精密に考慮していかないと今回みたいにデッドロックが多発する。要は適材適所
  • デフォルトのRead_Committedは、データ更新中は他のトランザクションにて読み込み処理の待ちが発生するが、READ_COMMITTED_SNAPSHOTをONにすることで読み込み待ちを回避できる。(余談:OracleのRead_Committedはもともと読み込み待ちは発生しないらしい)


3:トランザクション内には本当に必要な処理だけ入れよう


4:再現テスト環境は用意しておこう

  • DB環境は勿論、複数クライアントからの集中アクセスを擬似再現できる環境も。(SQLServerに接続してSQL文を繰り返し発行するVBSを作り、一度に複数起動させるだけでも結構面白い


デッドロックが起きた時の大雑把な調査】

  1. デッドロックのログをとるようDBMSを設定する。
  2. ログから、問題となるSQL文とロックの種類を特定する
  3. ソースにて、そのSQL文が組み込まれているトランザクションを確認し、処理順序の確認とおおまかなロック範囲の想定をする。
  4. ロック範囲を想定する上で、主キーやインデックスがどのように貼られているかを再確認する
  5. ロック範囲がかちあっていると思ったら、トランザクション分離レベルを確認し、かち合うと問題になるレベルになっていないか確認する
  6. 以上のことから、テーブルを見直すか、処理を見直すか、トランザクション分離レベルを見直すかを再検討する


ってなところですかねぇ。デッドロック調査に関しては、Microsoftのページを見るともっと専門的なやり方が書いてありますが理解不能だったのでまずは上記みたいに当たりをつけるやり方でもいいかなぁと。多分、ロック範囲を厳密に確認する必要がある場合はその理解不能なやり方を理解しないといけなさそうですねぇ。でもそこまで理解しちゃうと戻ってこられなさそうですなぁ……どこからどこへかは分からないけど

仮説検証

トラブルは出なくなったものの、何かやっぱり納得いかんので、同じ環境を作って実験してみた。


【検証方法】

  1. DBの構成を実際のものに合わせる。
  2. UPDATE→SELECTを1トランザクション内で行いそれを1万回繰り返すVBSを2つ作り、同時に起動する。


【結果】
うん、出ない(ノ^^)ノ


う〜ん、For文でひたすら回しまくれば、処理密度としては充分現場レベル(ってかはるかに超えてる)だから、あとはネットワーク越しってくらいしか環境として違うところは無いんだけれどなぁ。
ただ、これで少なくとも、スナップショットが原因でデッドロックになることは無さそう。良かった良かった。


って良くないわ〜。原因不明だわ〜。怖いわ〜。死ぬわ〜。


……ってコレ書いてる最中に思ったけど、もしかしてプログラム側でトランザクション分離レベルをSerializableにでもしてるのかしら……。(DB本体設定はRead Commited)
Serializableにしてたなら、READ_COMMITED_SNAPSHOTをONにしてようが何しようが、SELECT文でトランザクション終了待ちが発生しそうな気がする。二つのトランザクション同士でお互いのロック対象エリアを読み合ったら、ひょっとしてデッドロックになるんじゃ無かろうか。

明日ちょっと検証してみますかねぇ。

ってか、僕本当にデータベースのこと何もわかっとらんなぁ……

鍵の新たな死因……?

とりあえずこれまでの経緯をまとめると

  1. UPDATE文+SELECT文、もしくはUPDATE文+UPDATE文の組み合わせでデッドロックが発生した
  2. 更新ロックと共有ロックに共通する範囲があり、かつ同時に行われると発生する、変換デッドロックという現象だろうということが判明
  3. でも、別レコードに対するUPDATE文同士はかちあわないでしょと思っていたら、テーブルにそもそもインデックスが無く、全表走査が発生していたことが判明。
  4. テーブルに主キーをつけてロック範囲が限定されるようにして更新ロック同士がかちあわないようにし、更にREAD COMMITTED SNAPSHOTをONにして更新ロックと共有ロックがかちあわないように設定。
  5. そしたら今度は共有ロック同士でデッドロックが発生しちゃった
  6. どこが「共有」ロックだよバカヤロー

ただ、デッドロックログを見ると、100%SELECT文同士でしか起きていない為、少なくとも更新ロックとかち合うことは無くなったと見ていいかなと(ぽじてぃぶしんきんぐ)。


調べた結果、SELECT文同士でデッドロックは発生することが有るらしい。但し、それはヒントロックを使った更新ロックによるSELECT文同士がかち合った場合らしい。でもなぁ……今回はそんなことしてないし、デッドロックログでも共有ロック(MODE:S)であることは書かれているし、しかもこれはOracleでの事例らしいから、SQLServerで同じことが起きるかどうかは微妙。


ただ気になるのは、このSELECT文は
1:15秒に1回自動的に行う。
2:UPDATE文の処理完了後に1回行う。
の2つの処理で行われるのですが、この2番のパターンでしか端末上ではエラーが発生していないこと。もっと言えば、1番の処理はデッドロックを想定した再起処理を入れていない為、恐らく1番の処理では本当にデッドロックは起きていないんだろうなぁと思われる。
そうなると、2番の処理が複数端末でかち合ったときに、最後のSELECT文でデッドロックが起きているってことになるんだけれど……


……あ、ひょっとして……


プログラマに確認したところ、UPDATEとその後のSELECTは同一トランザクション内で行っているとのこと。


うーん……それは……直感的に言って気持ち悪い。別に根拠は無いのだけれど、UPDATEとSELECTのトランザクションは分けさせた。まあ、トランザクションは可能な限り小さくするのがセオリーですし。




……それ以降、ぱったりデッドロックは起きなくなりましたとさ。




う〜ん……解決……なのか?コレ。
いや、完全に直感で行ったことなので、何かモヤモヤするなぁ……。根拠が無いので、いつかまた再発しそうで怖い。


なので、根拠を作ってみた!


これまた直感だけれど、READ_COMMITTED_SNAPSHOTをONにしたことに関連があるのではないかなぁと考えていたら閃いた!根拠は閃くものなのか疑問だけれど。


もしかすると、UPDATEを含むトランザクションAを実行時に作成されるスナップショットを別トランザクションBで参照する際、AはBの参照時にかけているロックが外れるまでコミット待ちが発生するんじゃないかなと。この考えが正しければ、次の理屈でデッドロックが成り立つなぁと。

  1. UPDATE→SELECTを1処理とするトランザクションAが実行される。この時、スナップショットAが作成される
  2. 同時に、UPDATE→SELECTを1処理とするトランザクションBが実行される。この時、スナップショットBが作成される。
  3. トランザクションAは、UPDATE実行後にSELECTでスナップショットBを参照する。
  4. トランザクションBは、UPDATE実行後にSELECTでスナップショットAを参照する。
  5. トランザクションAは、スナップショットAをロックしているトランザクションBが終了するまで自身の終了を待つ
  6. トランザクションBは、スナップショットBをロックしているトランザクションAが終了するまで自身の終了を待つ
  7. あら、デッドロックの出来上がり


……うん、後付けで理屈をこぎつけたにしては筋が通っているんじゃなかろうか。恐らく、SELECT文そのものはちゃんと動作するんだろうけれど、そのロックが開放されるのはあくまでトランザクションのコミットが行われた時でしょうしね。確かにデッドロックではあるけど、普通はUPDATE→SELECTを1つのトランザクションにまとめることはしないだろうから(する意味もないし)、これと同一の現象も見当たらない。


このデッドロックって、一応変換デッドロックになるのかなぁ。でも、変換デッドロックとは理屈が違う気が。……ひょっとして新しいタイプのデッドロックを見つけてしまったのではなかろうか。スナップショットデッドロックってな感じの。
時間があったら検証してみるかねぇ



さてさて、理屈が解かったつもりになったところで、この件は一丁上がりってことになりそうかなぁ。




・・・・・・いや、別にその後も発生してないですよ。

鍵は二度死ぬ

ということで、デッドロックトラブルの続き。

前回のSELECTとUPDATEがかち合う理屈はわかった。UPDATE文は対象が1レコードだけだが、SELECT文はテーブル内のそれなりに広範囲のレコードが対象になるので、ロックがかち合う可能性はあるだろ〜な〜とも思う。

でも、ログをよく見てたら、何箇所かUPDATE文同士でデッドロックが起こってるものもあったりしちゃうのであった。



……ちょっと待て。これは有り得ないでしょ



[有り得ない理由その1]
この二つのUPDATE文は、同一テーブル内とは言え全く違うレコードが対象になっており、ロック範囲がそもそも違う筈。というか運用ルール上、あるUPDATEのトランザクションでかかるロック範囲が、別のUPDATEのトランザクションのロック範囲と部分的にすら重複することも無い筈。

[有り得ない理由その2]
そもそも、UPDATEに使用される更新ロックは重ねがけされない為、デッドロックではなく、どちらかが待ち状態になる筈。

う〜ん……何だコレ……。

まあ、考えても時間と青春とサービス残業の無駄遣いなので、まずは変換デッドロックの際の対策を調べてみた。

【変換デッドロックが起きたら……】
1:データベースのパフォーマンスを見直す。場合によってはインデックスの貼り方などを調整してみる。
2:SELECT文に、ヒントロックをつけてロックタイプを強制的に更新ロックに変更する。

3:取り敢えず、デッドロックが出るのはしょうがないものとみなし、プログラム側でデッドロックをキャッチしたときに自動リトライをかける。

4:DBの設定でREAD_COMMITTED_SNAPSHOTをONにすることで、更新ロック中に別のリクエストが有った場合に、更新ロック直前のデータを見せるようにする。


対策はこんな感じなんだけど・・・う〜ん。
まず、2は論外。理由は、既にUPDATE文同士(つまり更新ロック同士)でデッドロックが起きている為、更新ロックでSELECT文をリクエストするようにしたとしても、同じような結果が見えてる。それに、仮にこれで解決したとしても、運用上で待ちが頻発する。

次に3だけど、これはデッドロックが発生した当日に対応済み。ただ、これは本当にどうしても極々稀に偶発的に発生してしまう場合向けの対策だよなぁ……。少なくともSQLServerのログには、大量のデッドロックの痕跡が出ており、これを放置するのは根本的な対策とは言いがたい。

4はパッと見効果有りそうだなぁと思う。設定にはDBを止める必要があるけれど、非運用時間帯があるので問題無いし。
ただ、これをOFFにしてるもっとでかいシステムもあるだろうし、何か根本的な原因は他に有りそうな……

ってことは、やっぱり1は避けて通れないか……。僕、出来ればデータベースには余りディープに関わりたくないんですが。ロクなことが無さそうなので……

まあ、まずはどんな感じにインデックスを貼ってるかを調べますか……。えーと、Management Studioを開いて、さてさてテーブルAのインデックスはっと……あれ、貼ってない。

……ん?

インデックスが無い!?

そう!クラスタ化インデックスすらない。
つまり主キーが無い。
ディープに関わるどころかイロハのイで大コケ。このテーブルを作ったのは誰だぁ!プロマネを呼べ!

主キーが無いと、WHERE句を使おうが何しようが、常にテーブル内の全行を走査することになるはず。だったら、ロック範囲も常にテーブル全体にかかるんだろーなぁ。ついでに、UPDATEにかかる時間も増大する為、ロック時間も長くなるよねぇ。これはマズい……

てことで、自前で作っておいたテスト環境でテーブル構成の変更テスト。どの列の組み合わせで一意になるかは把握しているので、その列を主キーにして、一通りの機能テスト。うん、重複データはエラーになるし、実際のデータ登録時に弾かれたりもしないな。
てことで、暫くこれで様子を見てもらうことにした。
あと、このシステムはテーブルの更新頻度は激しいけど、1回に更新するデータ量はそんなに多くなく、定期的に行われるSELECTもそこまで厳密な結果が帰る必要もない。なので、READ_COMMITTED_SNAPSHOTはONの方が良いんじゃないかなぁと思い、こちらも設定。

【READ_COMMITTED_SNAPSHOTの変更の仕方】
1:DBに接続しているシステムを全て終了する。
2:Management Studioで、対象のDBを右クリックし、オフラインにする
3:同じくManagement Studioでクエリアナライザを開き、下記のコマンドを入力する。

  ALTER DATABASE DB名
  SET READ_COMMITTED_SNAPSHOT ON

4:Management Studioで、対象のDBを右クリックし、オンラインにする 

以上。多分これで、少なくともUPDATEは当初の予定通り、ロック範囲が重なることは無い筈。後は、SELECT文がスナップショットの方を参照してくれれば、今回のようなことにはならないと思う。うん。

ただ、一点気になるのは、ロック範囲が肥大化するとはいえ、UPDATE文同士はやっぱり「デッドロック」じゃなく「待ち」になるはずじゃないのかなぁってところ。更新ロックが一時的に「共有ロック」の特性を持つところに何かからくりがあるのかねぇ。もしくは、ヒープテーブル(主キーの無いテーブル)での動作に限っては、何か特別な動きをするのかしら。

まあ、それはおいおい調べて躓いて挫折しましょうかね。さてさて、今回の件は一丁上がりかな。


……と思ったら、二日後にやっぱりデッドロックが発生したorz
ただ、アプリケーションがエラーを出しているタイミングが若干違うなぁ……。まあ、同じようにログを追ってみますか……


……はぇっ!?←本当にこんな声が出た


え……あの……セ、SELECT文同士でデッドロックが起きてるんですが……しかも、今回は全てのログにおいて

え〜も〜やだ〜……何なのこれ……。
MicroSoftの陰謀だと信じたい……

鍵は死んだ

デンっ!

うん、日本語でおk
ロック個のリソースって何よ

それはともかく、どうやらついに出くわしてしまったようだ。デッドロックデッドロックねぇ……デッドロックって何だっけ。ポン酢醤油のある家だっけ。
アレだよね、Aテーブル→Bテーブルの順で更新する処理と、Bテーブル→Aテーブルの順で更新する処理が同時に行われると、お互いにお互いの処理が終わるのを待ち続けて動かなくなるという、聖闘士星矢で言うところの千日戦争状態になることだよね。

さて困った。デッドロックは未経験なのです。ってか、自分の携わるシステム規模から言って、まず遭遇することは無いだろうと思っていたんですけどね。なので、予備知識が殆どナシの状態からトラブルシュートスタート。ほんとこういう時ネットって便利。

……ただ、今回のシステム、別にデータ更新処理なんか2処理しかないし、そもそも複数のテーブルなんか参照しない(JOINもしない)んだけどなぁ……

======================================================
[BGM:ファイナルファンタジータクティクス戦闘準備]
【今回の環境】
DBMS:SQLServer2008ExpressR2
・アクセスクライアント数:10台前後
・各クライアント1分に1回くらいはデータの更新を行い、15秒に1回データの参照を行っている。
・データ件数:対象となる業務テーブルでは2万レコード超えない程度。

【おおまかな調査手順】
1:DBMSデッドロックのログをキャッチするよう設定
2:現象が再現するのを待つ
3:ログを確認
4:その後はその後の風が吹く


●ログの取り方●
1:Management Studioを開き、SQLウインドウで下記の二つのコマンドを打ち込む。

DBCC TRACEON(1204,-1)
DBCC TRACEON(3605,-1)
※ログ取りは1204のフラグだけでもいいのだけれど、3605もオンにしておかないと、セッション終了時にログ取りも終了してしまうらしい。

2:現象が発生したら、そのDBが存在するインスタンスのフォルダの中から、ERRORLOGファイルを取得する
(パス例:C:\Program Files\MicrosoftSQLServer\MSSQL10.50\MSSQL\Log\ERRORLOG)
※ERRORLOGは拡張子無しのファイル

3:ERRORLOGをメモ帳で開く。眺めていると、な〜んとなくデッドロックしてる処理の部分が解かるかもしれない。
============================================================

ってな感じでやってみました。で、何にも解からないけど、とりあえずログを眺めてみますかねえ……って、あ、見っけた。……ってか、結構発生しているなぁ。

ログの各々の意味はサッパリ解からんかったし、多分自分には一生理解不能な内容なんだろうなという感じだったのですが、デッドロックが発生したSQL文はすぐに解かります。結構ログの中で主張してます。さてさて、どんな処理がデッドロックになっていることやら……ってあれ……?

[SQL文その1]
UPDATE テーブルA SET データA = '1',TimeStamp = '2014/XX/XX XX:XX:XX',データB = '001' WHERE データC = '1234567890' AND データD = '1'

[SQL文その2]
SELECT COUNT(*) FROM (SELECT テーブルA.データC FROM テーブルA WHERE テーブルA.データA = '1' GROUP BY テーブルA.データC)

う〜ん……?えーと、UPDATE文とSELECT文がかち合ってデッドロックになってるの……?それに、やっぱりテーブルも1つしか使ってないし、この状況でデッドロックってどういうことだろう……

と思ったら、これと同じ状況のデッドロックに関する記事があっちこっちで見つかった。どうもこれは、SQLServer特有の「変換デッドロック」って奴らしい。ってか、デッドロックってそんな何種類あるものだったんだ。よく死ぬ鍵だなぁ。
どうも、下記のような理屈で起きるらしい。(素人が適当に理解した内容なので、真に受けないように)

1:SELECT文を発行するとき、対象範囲に「共有ロック」がかかる
2:UPDATE文を発行するとき、対象範囲に「更新ロック」がかかる
3:共有ロックと更新ロックは、別処理から同時にかけることが出来る。
4:更新ロックは、内部的にはデータ走査時は「共有ロック」、データ更新時に「排他ロック」として動作する。
5:上記ロックが同時にかけられた際、共有ロック側(つまりSELECT文側)は、最新のデータを参照したい為、更新処理を待つ
6:一方、更新ロック側(つまりUPDATE文側)からは、データ更新の為に「排他ロック」に変更するべく、「共有ロック」の開放を待つ(排他ロックは、他のロックと共存できない為)
7:あら、デッドロックの出来上がり。

理屈はなんとなく理解したし、変換デッドロックの現象と一致するので、恐らくこれなんだろうというところまでは解かったけど、さて、これどうやって解決すりゃいいってんでしょ。教えてエラくもエロくも無い人