View
1.969
Download
0
Embed Size (px)
Citation preview
Copyright © 2015 NTT DATA Corporation
2015年1月31日 株式会社NTT DATA
NTT DATA と PostgreSQL が挑んだ総力戦 裏話
~ セミナでは話せなかったこと ~
2 Copyright © 2015 NTT DATA Corporation
はじめに
本日のお話は、2014/12/5 の PostgreSQL カンファレンスの基調講演で
話せなかった技術的(特にトラブルにまつわる)な話のトピックを裏話と称して
お伝えするものです。
前半は先日の基調講演のダイジェスト、後半は特に手ごわかった(でも技術的には
興味深い)2つのトラブルについて、解決に至る過程と併せて紹介します。
PostgreSQLや周辺ツールのコアに関わる未知のバグに遭遇したケース、
そしてその解析などに関して、Hackers ML を除き、あまり世の中には
情報がないと思います。
有益な情報ではないかもしれませんが、何かのヒントになれば幸いです。
3 Copyright © 2015 NTT DATA Corporation
PostgreSQLカンファレンス 2014 の内容を振り返り。
以下の資料と同内容です
www.slideshare.net/hadoopxnttdata/ntt-data-postgresql
4 Copyright © 2015 NTT DATA Corporation
今日の本題
様々なトラブルがありました。
特に手を焼いた 2つ のトラブルを紹介。
・ SEGVでPostgreSQLが落ちた
・ SELECT結果が間違っている。
共通しているのは、いずれもPostgreSQL、周辺ツールの
バグであったこと。いずれも解決済みです。
5 Copyright © 2015 NTT DATA Corporation
SEGVでPostgreSQLが落ちた
1. ある日、本番環境での試験中にPostgreSQLがダウンした
2. とあるSQLが実行された際に、Segmentation Fault が発生していた PostgreSQLは、あるバックエンドプロセスがSEGVを出すと、インスタンス全体が落ち
ますね
3. 復旧後、同じSQLを発行したら、再度 PostgreSQL がダウン
4. とりあえず、そのSQLの発行を停止して様子見(その後はダウンせず)
SQLは特に変哲もないもの。今までは特に問題なかった。
では、直前に何かしたか?
・・・・ そういえばパーティションのローテートをした。
あと残されたのは core ファイル。
まずはここから解析が始まった。
6 Copyright © 2015 NTT DATA Corporation
coreの解析
実行計画作成中に統計情報を見ている箇所でクラッシュしている
(gdb) bt #0 pg_detoast_datum (datum=0x1d7f0f2c28ee3) at fmgr.c:2240
#1 0x000000000069c7b4 in numeric_lt (fcinfo=0x7fffaee58560) at numeric.c:1408
#2 0x000000000071a8c0 in FunctionCall2Coll (flinfo=<value optimized out>, collation=<value optimized out>, arg1=<value optimized out>, arg2=<value optimized out>) at fmgr.c:1326
#3 0x00000000006c320f in ineq_histogram_selectivity (root=0x107ab28, vardata=0x7fffaee58a80,
opproc=0x7fffaee589f0, isgt=0 '¥000', constval=17280952, consttype=1700) at selfuncs.c:834 #4 0x00000000006c3a8c in scalarineqsel (root=0x107ab28, operator=<value optimized out>, isgt=0 '¥000', vardata=0x7fffaee58a80, constval=17280952, consttype=<value optimized out>) at selfuncs.c:558
#5 0x00000000006c470c in scalarltsel (fcinfo=<value optimized out>) at selfuncs.c:1021
#6 0x000000000071cdb7 in OidFunctionCall4Coll (functionId=<value optimized out>, collation=0, arg1=17279784, arg2=1754, arg3=17280664, arg4=0) at fmgr.c:1682 #7 0x00000000005fc0e5 in restriction_selectivity (root=0x107ab28, operatorid=1754, args=0x107ae98, inputcollid=0, varRelid=0) at plancat.c:1021 #8 0x00000000005d079e in clause_selectivity (root=0x107ab28, clause=0x107b040, varRelid=0, jointype=JOIN_INNER, sjinfo=0x0) at clausesel.c:668 #9 0x00000000005d0274 in clauselist_selectivity (root=0x107ab28, clauses=<value optimized out>, varRelid=0, jointype=JOIN_INNER, sjinfo=0x0) at clausesel.c:108 #10 0x00000000005d1dad in set_baserel_size_estimates (root=0x107ab28, rel=0x107b120) at costsize.c:3411
#11 0x00000000005cec4a in set_plain_rel_size (root=0x107ab28, rel=0x107b120, rti=1, rte=0x1044090) at
allpaths.c:361
7 Copyright © 2015 NTT DATA Corporation
coreの解析
ちょっと追うと、何故か異なるテーブルの統計情報を見ていた・・・
(gdb) frame 3 #3 0x00000000006c320f in ineq_histogram_selectivity (root=0x107ab28, vardata=0x7fffaee58a80, opproc=0x7fffaee589f0, isgt=0 '¥000', constval=17280952, consttype=1700) at selfuncs.c:834 (gdb) p *vardata $15 = {var = 0x107ae28, rel = 0x107b120, statsTuple = 0x7f2bd2ee1da0, freefunc = 0x7f2bd5921830
<FreeHeapTuple>, vartype = 1700, atttype = 1700, atttypmod = -1,
isunique = 0 '¥000'} (gdb) p *(Form_pg_statistic) ((char *) vardata->statsTuple->t_data + vardata->statsTuple->t_data->t_hoff)
$14 = {starelid = 299892885, staattnum = 1, stainherit = 0 '¥000', stanullfrac = 0, stawidth = 8, stadistinct
= -1, stakind1 = 2, stakind2 = 3, stakind3 = 0,
stakind4 = 0, stakind5 = 0, staop1 = 2062, staop2 = 2062, staop3 = 0, staop4 = 0, staop5 = 0}
問題のSQLで参照しているテーブルのOID は 299106453。numeric型(oid=1700)の列に対し、WHERE句で col < xxxx の条件を使っていた。
しかし、上記のcoreからは OID = 299892885 のテーブルのtimestamp型(oid = 2062)の列の統計情報を
参照し、可変長データのアクセスロジックに行ってしまい、クラッシュしていた。
8 Copyright © 2015 NTT DATA Corporation
pg_dbms_statsで問題がある?
1. 統計情報固定化ツール(pg_dbms_stats)を使っている
まずはこいつを怪しむ
2. 固定化した統計情報の中身がまずいのか?
中身を確認するも、問題なし
3. 別の試験環境に当該の統計情報を持ってきてSQLを 発行しても大丈夫
当該環境でしか発生しない様子・・
ここまでの情報とコード解析を行った結果、特定の条件において、pg_dbms_statsが誤った統計情報を返していることが分かった。
9 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
relid attnum stats
Planning Phase Execute Phase
バックエンドプロセスのローカルメモリ
10 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
Hook
relid attnum stats
Planning Phase
統計情報取得
Execute Phase
① 統計情報の取得時、pg_dbms_statsが有効になっていると、Hook経由でpg_dbms_statsのロジックに入る
バックエンドプロセスのローカルメモリ
11 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
Hook
relid attnum stats
Planning Phase
統計情報取得
Execute Phase
②固定化された統計情報と本来の統計情報(pg_statistics)をマージし、固定化されたものがあれば、それを使い、なければ本来の統計情報を使う
バックエンドプロセスのローカルメモリ
12 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
Hook
relid attnum stats
Planning Phase
統計情報取得
Execute Phase
バックエンドプロセスのローカルメモリ
③次回以降の参照のため、取得した統計情報はローカルメモリのハッシュへ格納しておく
13 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
Hook
relid attnum stats
Planning Phase
統計情報取得
Execute Phase
バックエンドプロセスのローカルメモリ
④統計情報を取得し、Hookのポイントへ返る
14 Copyright © 2015 NTT DATA Corporation
その前に、pg_dbms_statsの内部について
relid attnum stats
固定化した統計情報 本来の統計情報
pg_dbms_stats
Hash
Hook
relid attnum stats
Planning Phase
統計情報取得
Execute Phase
バックエンドプロセスのローカルメモリ
①’ 二回目以降、Hashに統計情報があれば、それを使う
relid, attnum, stats
15 Copyright © 2015 NTT DATA Corporation
Hashの仕組み
Hash
Hashは ハッシュキーと値の形でデータを管理。ロック情報管理などで使われている。
これを外部モジュール(contribやbgworker)等でも使えるよう、汎用の
ユーティティリティとしてPostgreSQLのコアが提供している。
・ Hash化、マッチングは組み込み or ユーザ定義を利用
・ Hashからの検索はキーサーチ、あるいはSeq_search
・ ローカル or 共有バッファのどちらにもおける
・ 共有バッファの場合は排他制御用のロック構造体を指定 (パーティション化もサポート)
・ 上記はHashの作成時に指定
hash_fn(ユーザ定義 or 組み込み(oid, string など)) bucket
bucket
bucket
hash_serach()など
match_fn(ユーザ定義 or 組み込み(memcmpなど))
key +
body key +
body
key
該当のbucketの中から、規定の方法で
キーマッチングを実施
16 Copyright © 2015 NTT DATA Corporation
今回のバグは・・
仕組みとしてはrelationのOID(relid)をキーとしていたが・・Hash_createの
引数フラグにHASH_FUNCTIONを指定していなかった・・
MemSet(&ctl, 0, sizeof(ctl)); ctl.keysize = sizeof(Oid); ctl.entrysize = sizeof(StatsRelationEntry); ctl.hash = oid_hash; ctl.hcxt = CacheMemoryContext; hash = hash_create("dbms_stats relation statistics cache", MAX_REL_CACHE, &ctl, HASH_ELEM | HASH_CONTEXT | HASH_FUNCTION ); rel_stats = hash;
この部分が無かった
pg_dbms_stats.c より
17 Copyright © 2015 NTT DATA Corporation
今回のバグは・・
そのため、ハッシュ化の関数として想定していたoid_hash は使われず、OIDを 文字列としてハッシュ化する string_hashが設定されていた。
/* Initialize the hash header, plus a copy of the table name */ hashp = (HTAB *) DynaHashAlloc(sizeof(HTAB) + strlen(tabname) +1); MemSet(hashp, 0, sizeof(HTAB)); hashp->tabname = (char *) (hashp + 1); strcpy(hashp->tabname, tabname); if (flags & HASH_FUNCTION) hashp->hash = info->hash; else hashp->hash = string_hash; /* default hash function */
hash化の関数にstring用の 関数が選択される
src/backend/utils/hash/dynahash.c より
18 Copyright © 2015 NTT DATA Corporation
今回のバグは・・
そしてHashサーチ時のマッチング手段も文字列として設定されていた
/* * If you don't specify a match function, it defaults to string_compare if * you used string_hash (either explicitly or by default) and to memcmp * otherwise. (Prior to PostgreSQL 7.4, memcmp was always used.) */ if (flags & HASH_COMPARE) hashp->match = info->match; else if (hashp->hash == string_hash) hashp->match = (HashCompareFunc) string_compare; else hashp->match = memcmp;
src/backend/utils/hash/dynahash.c より
19 Copyright © 2015 NTT DATA Corporation
今回のバグは・・
・ relid = 299106453 ==> 文字列で見てしまうと ==> 0x11d40095
と
・ relid = 299892885 ==> 文字列で見てしまうと ==> 0x11e00095
となる。ハッシュ化や文字列マッチング(比較)では0x00を終端として扱い、 上記2つは「95」として同じものとなってしまう・・・つまり・・
Hash
hash_string
bucket
bucket
bucket
hash_serach()
string_cmp(0x95, 0x95)
relid = 299892885 stats = XXXXX
stats = XXXXX
key = relid = 299106453
0x95でhash化
0x95でhash化
relid = 299106453ではなく relid = 299892885の統計情報を返す
20 Copyright © 2015 NTT DATA Corporation
解決
というわけで、突然SEGVが発生した理由がわかった。
・ OIDに0x00を含み、かつその下位バイトが同じになるテーブルを 同じクライアントが読む時にだけ発生
・ ハッシュは各クライアントのローカルメモリにあるから
・ 突然発生したのは、パーティションローテで偶然にも上記に 該当するテーブルを読む状況が揃ってしまったため
・ 別環境で再現しなかったのは、pg_dbms_statsの統計情報のexport/importは、OIDは引き連れていかないから
修正は、hash_create()のフラグに正しく HASH_FUNCTIONを立てるのみ。
coreが無かったら解析は難航していた。最悪、迷宮入りになった可能性もある・・
21 Copyright © 2015 NTT DATA Corporation
SELECT結果が間違っている !?
1. 試験環境にて、AP側でSELECT結果がおかしい事象が出た。
2. AP側のログ解析から 「SELECT FOR UPDATEで未処理ステータスの行を取得し、ロックした行のステータスを処理済みにUPDATEしている。この処理において、なぜか処理済みのステータスの行をSELECT FOR UPDATEで取得してしまう」 という事象に見えた。
3. たまに出ていた。1日1回出るか出ないかの頻度。
SQLは特に変哲もないもの。
PostgreSQLに原因があるというが、前例がない・・
APのバグでは?いや、JDBC?JVM?・・
容疑者も当時の状況では絞り切れていない
22 Copyright © 2015 NTT DATA Corporation
切り分けしよう
• 幸い、試験環境でも出ていたので、発生時と同様の業務APも使えるし、テストデータもある
• まず、PostgreSQL <-> JDBC <-> AP で切り分けをする • tcpdumpやWiresharkでNW上のデータをキャプチャする案もあったが、時間の都合
と環境の問題で、とりあえず見送り
• 当該の事象が発生した業務処理を高頻度で行い加速試験
PostgreSQL JDBC 業務AP
PostgreSQL側でプローブを仕込む。ここで検知できればPostgreSQLがアウト
(詳細は次項)
JDBCのResultSetにプローブを仕込む。ここで検知で
きたらJDBCがアウト (結果的にこれは未実施)
APのResultSetにプローブを仕込む。ここで検知でき
たらJDBCがアウト
23 Copyright © 2015 NTT DATA Corporation
PostgreSQLを容疑者と仮定してのプローブ
1. 発生するとAPが停止する可能性もあるので、SELECT条件と矛盾するデータは弾きつつ、検知もしたい
2. フィルタ関数で当該SQLを囲ってしまおう
3. Limit & OFFSETを使っているので、負荷も低い
SELECT … FROM … WHERE … LIMIT .. FOR UPDATE;
SELECT * FROM ( SELECT … FROM … WHERE … LIMIT .. ) s1 WHERE check_filter(s1.xxx) FOR UPDATE
check_filter()は、引数をチェックして、サブクエリの条件と矛盾する行が来たら falseを出しつつ、
内部でRAISE LOG ‘inavalid status xxxx’ を出力
Before
After
24 Copyright © 2015 NTT DATA Corporation
出た
異常検知メッセージがPostgreSQLのフィルタ関数で出てしまった。
つまりPostgreSQLが黒だった。
急いで手元環境で再現できるよう、再現パターンを絞り込みつつ、
APの該当処理をJavaで作成。結果的に、手元でも再現した。
さらにミニマムなテストセットにより、psql 上でも確認できた・・・・
絞り込めた条件は、
1. パーティショニングされており、
2. 部分インデックスを使ったインデックススキャンを経由した
3. UPDATEとFOR UPDATE が競合する
こと。
ここからはコード解析も併せて問題の特定へ。
25 Copyright © 2015 NTT DATA Corporation
ヒントだったもの
実行計画を見ると、IndexScanの条件(IndexCond/Filter)がおかしい(事項から)
ソースコードとREADME(src/backend/executor/README など)から、部分インデックスの際の特別な最適化のコードがあることが分かる。
これらをヒントに、仮説と試験をして、原因が解明できた。
26 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
PostgreSQLのオプティマイザの問題だった
部分インデックス経由のインデックスアクセスをする際、部分インデックスの定義と同様のWHERE条件は、実行時に省略する最適化がされる
-> 部分インデックスのエントリには、その条件に合致するものしかないため
postgres=# EXPLAIN SELECT * FROM tbl WHERE c1 < 100; QUERY PLAN ----------------------------------------------------------- Index Scan using tbl_idx2 on tbl (cost=0.00..208.79 rows=100 width=10) Index Cond: (c1 < 100) (2 rows) postgres=# EXPLAIN SELECT * FROM tbl WHERE (c3 = '0' OR c3 = '1' OR c3 = '2'); QUERY PLAN ----------------------------------------------------------- Index Scan using tbl_idx on tbl (cost=0.00..114.34 rows=305 width=10) (1 row)
Indexのスキャン条件が無い!
27 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
PostgreSQLのオプティマイザの問題だった
PostgreSQLでは、FOR UPDATE やUPDATEなどの行ロックを必要とする場合、SELECT結果についてロック取得時に改めて再確認(EvalPlanQual)する -> ロック取得までにSELECT対象外のデータへ更新されている可能性があるので
postgres=# EXPLAIN SELECT * FROM tbl WHERE (c3 = '0' OR c3 = '1' OR c3 = '2') FOR UPDATE; QUERY PLAN --------------------------------------------------------------------- LockRows (cost=0.00..117.39 rows=305 width=16) -> Index Scan using tbl_idx on tbl (cost=0.00..114.34 rows=305 width=16) Filter: ((c3 = '0'::bpchar) OR (c3 = '1'::bpchar) OR (c3 = '2'::bpchar)) (3 rows)
そのため、部分インデックスを使っていたとしても、SELECT FOR UPDATE時にはWHERE句条件の省略最適化はしないようにしている・・・はずだった。
Indexのスキャン条件がある!
28 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
ところが・・
postgres=# EXPLAIN SELECT * FROM tbl WHERE (c3 = '0' OR c3 = '1' OR c3 = '2') FOR UPDATE;
QUERY PLAN
----------------------------------------------------------------------
LockRows (cost=0.00..198.78 rows=337 width=23)
-> Result (cost=0.00..195.41 rows=337 width=23)
-> Append (cost=0.00..195.41 rows=337 width=23)
-> Index Scan using tbl_idx on tbl (cost=0.00..114.34 rows=305 width=20)
-> Index Scan using tbl_c1_c3_c2_idx on tbl_c1 tbl (cost=0.00..40.54 rows=16 width=54)
-> Index Scan using tbl_c2_c3_c2_idx on tbl_c2 tbl (cost=0.00..40.54 rows=16 width=54)
Indexのスキャン条件が無い!
29 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
パーティションの子テーブル部分については、ただしく SELECT FOR UPDATEされているかどうかの判定がされていなかった・・
本来ならPlannerInfoに紐づくPlanRowMarks(どのテーブルがFOR UPDATE等
の対象かを示す情報)を元にFOR UPDATEの有無を判定をすべきだったのが
PlannerInfo->Query を元に判定していた・・・
Tom Lane 曰く、”thinko (うっかり勘違い)” のミス。
【本バグの修正コミット】
http://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=cd63c57e5cbfc16239aa6837f8b7043a721cdd28
30 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
つまり子テーブルに関しては FOR UPDATE対象外と判定され、部分インデックスの最適化対象となり・・
最終的に、子テーブルへの部分インデックス経由のアクセスの際、直前に更新された結果でも誤って返却していた。
id status other
1 0 ZZ
2 0 ZZ
3 0 ZZ
1 9 ZZ
① ある処理が UPDATE tbl SET status = 9 WHERE status = 0 ORDER BY id LIMIT 1; を実施
② 別の処理が SELECT * FROM tbl WHERE status = 0 ORDER BY id LIMIT 1; を実施
①の更新が終わるまで待つ
31 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
つまり子テーブルに関しては FOR UPDATE対象外と判定され、部分インデックスの最適化対象となり・・
最終的に、子テーブルへの部分インデックス経由のアクセスの際、直前に更新された結果でも誤って返却していた。
id status other
1 0 ZZ
2 0 ZZ
3 0 ZZ
1 9 ZZ
③ COMMITする。 ④ SELECT * FROM tbl WHERE status = 0 ORDER BY id LIMIT 1; が走る
更新結果を参照する。
32 Copyright © 2015 NTT DATA Corporation
今回のバグは・・・
つまり子テーブルに関しては FOR UPDATE対象外と判定され、部分インデックスの最適化対象となり・・
最終的に、子テーブルへの部分インデックス経由のアクセスの際、直前に更新された結果でも誤って返却していた。
id status other
1 0 ZZ
2 0 ZZ
3 0 ZZ
1 9 ZZ
⑤ SELECT * FROM tbl WHERE status = 0 ORDER BY id LIMIT 1; の結果 status = 9 のものが返ってくる
本来であれば WHERE句 条件の status = 0 の再確認をすべきだが それをしないために、status = 9
であっても返してしまう
33 Copyright © 2015 NTT DATA Corporation
解決
というわけで、SELECT結果が間違っていた理由がわかった。
・ 再現頻度の低さは、当該のSQLは FOR UPDATE NOWAIT をしており、
よりシビアな条件だったから
一応、歯止めのフィルタ関数もあり、かつ発生条件に該当するSQLはそこだけだった。
(パーティションかつ部分インデックスを使っているSQLなので、絞り込みやすいことが幸いだった・・)
34 Copyright © 2015 NTT DATA Corporation
振り返って
エンタープライズ、に限りませんが、
深刻な問題は根本原因を特定すること
= ホワイトボックスとして明確にすることが大切です。
今回の事象も、根本原因が分かったため、
・なぜ今まで発生頻度が低かったか?
・不具合の発生影響が他にないのか?
・検討した対処が本当に正しいのか?
を見極めることができました。
PostgreSQLのコードには、親切なREADMEやコメントが多数あります。
これらの情報やcoreの情報、問題発生状況を元に、地道に検査していけば
多くの場合は何とかなります・・多分。
bugs ML などを通じてコミュニティに報告するのもアリです。ただし、必ず
再現資材(self-contained test case)を添えましょう。