FORCIA CUBEフォルシアの情報を多面的に発信するブログ

postgresql で実行したSQLのシステムコールを眺めてみる

2019.12.10

アドベントカレンダー2019 postgresql

FORCIAアドベントカレンダー2019 10日目の記事です。

検索プラットフォーム事業部の澁谷です。

皆さん、システムコールって意識していますか?
昔からあるデバック方法の一つですが、最近の開発で「システムコール」を意識することも少なくなっている気がします。今回はシステムコールのデバックコマンド [strace ] の紹介がてら、postgresql で実行したSQLの挙動を眺めてみます。

システムコールとは?

システムコールとは、コンピュータ上で実行中のプログラムが、オペレーティングシステム(OS)のカーネルの特権的な機能を呼び出す仕組み。ネットワークを利用した通信、ファイルへの入出力、新しいプロセスの生成、プロセス間通信などは、システムコールを使用することで実現されます。
なお、CPU・メモリ上の計算であればシステムコールは発生しません。

どうやってトレースする?

linux (ubuntu ) の strace コマンドを使用し、トレースしたいプロセスのPIDを事前もしくは実行中に調べ、strace コマンド の [p]オプションへ指定すると確認することができます。

例えば、apache の親プロセスをトレースすると以下のようになります。
select,read.fstat,write,waitid などがシステムコールになります。

$ sudo strace -p 1339
strace: Process 1339 attached
select(48, [3 5 6 7 8 9 11 12 13 15 18 19 20 22 23 26 27 29 32 34 35 37 39 41 43 44 45 47], [], [7 8 9 11 12 15 19], NULL) = 1 (in [23])
read(23, "[20194:20194:1128/134821.397022:"..., 8192) = 117
read(23, 0x55e2ab413285, 8075)          = -1 EAGAIN (Resource temporarily unavailable)
fstat(14, {st_mode=S_IFREG|0640, st_size=5046, ...}) = 0
write(14, "[20194:20194:1128/134821.397022:"..., 117) = 117
read(3, 0x7ffc0e3bf577, 1)              = -1 EAGAIN (Resource temporarily unavailable)
waitid(P_ALL, 0, {}, WNOHANG|WEXITED|WSTOPPED|WCONTINUED, NULL) = 0
select(48, [3 5 6 7 8 9 11 12 13 15 18 19 20 22 23 26 27 29 32 34 35 37 39 41 43 44 45 47], [], [7 8 9 11 12 15 19], NULL^Cstrace: Process 1339 detached
<detached ...>

事前準備

検証用データの作成

検索用情報を持つテーブル foo と検索情報に紐づくテキスト情報を持つ bar テーブルを作成。内部結合を行った際の処理と、select句内でサブクエリを記述し取得した際の処理をそれぞれトレースします。

用意するデータのイメージは以下の通り

検索用情報テーブル (foo)

ID1ID2ID3bit1bit2bit3
13100truetruetrue
25300truetruefalse
..................
1000017500falsefalsetrue

テキスト情報テーブル (bar)

ID1text1
1ふなっしー
2くまもん
......
10000素敵坊主

今回の検証用SQLで得られる結果(id2 - bit3 は返却対象から外しています)

ID1ID2ID3bit1bit2bit3text1
13100truetruetrueふなっしー
25300truetruefalseくまもん
.....................
1000017500falsefalsetrue素敵坊主
-- 検索用情報テーブル
CREATE TABLE foo (
  id1 int4,
  id2 int4,
  id3 int4,
  id4 int4,
  bit1 bit(1),
  bit2 bit(1),
  bit3 bit(1),
  bit4 bit(1)
); 
insert into foo
select
  n as id1,
  n as id2,
  n as id3,
  n as id4,
  '1'::bit as bit1,
  '1'::bit as bit2,
  '1'::bit as bit3,
  '1'::bit as bit4
from (
	SELECT GENERATE_SERIES(1, 10000) as n
)x;

-- テキスト情報テーブル
CREATE TABLE bar (
  id1 int4,
  text1 text
); 
insert into bar
select
  n as id1,
  'hoge'::text as text1
from (
	SELECT GENERATE_SERIES(1, 10000) as n
)x

strace で プロセスをトレースする準備

straceコマンドでSQL実行中のプロセスをトレースするため、SQLを実行するアプリケーションpgadmin からSQLを実行しPIDを確認します。

$ ps -ax | grep postgres | grep SELECT
28495 ?        Rs    10:34 postgres: forcia forcia 10.0.2.2(64164) SELECT 

ここでは PID 28495が得られたので、そのプロセスを追跡していくこととします。

同一の処理結果となる 2つのSQLを比較

SQL1.内部結合で取得

select 
    b.text1
from foo a
inner join bar b using(id1);

-- explain analyze
"Hash Join  (cost=319.00..506.12 rows=5184 width=32) (actual time=4.277..9.833 rows=10000 loops=1)"
"  Hash Cond: (b.id1 = a.id1)"
"  ->  Seq Scan on bar b  (cost=0.00..115.84 rows=5184 width=36) (actual time=0.013..1.626 rows=10000 loops=1)"
"  ->  Hash  (cost=194.00..194.00 rows=10000 width=4) (actual time=4.253..4.253 rows=10000 loops=1)"
"        Buckets: 16384  Batches: 1  Memory Usage: 480kB"
"        ->  Seq Scan on foo a  (cost=0.00..194.00 rows=10000 width=4) (actual time=0.005..2.116 rows=10000 loops=1)"
"Planning time: 0.086 ms"
"Execution time: 10.485 ms"

SQL2.セレクト句にサブクエリを記述

select 
    (select text1 from bar b where b.id1 = a.id1) as text1
from foo a;

-- explain analyze
"Seq Scan on foo a  (cost=0.00..1890194.00 rows=10000 width=32) (actual time=1.801..19942.947 rows=10000 loops=1)"
"  SubPlan 1"
"    ->  Seq Scan on bar b  (cost=0.00..189.00 rows=1 width=5) (actual time=1.015..1.990 rows=1 loops=10000)"
"          Filter: (id1 = a.id1)"
"          Rows Removed by Filter: 9999"
"Planning time: 0.078 ms"
"Execution time: 19946.171 ms"

SQL1,SQL2それぞれの処理速度(Execution time)を見ると、SQL1の10.485ms に対して、SQL2は19946.171 msで、SQL2はSQL1よりも約2000倍遅い。

SQL1、SQL2の実行プランを比較すると、bar テーブル の処理はどちらもシーケンシャルスキャンですが、rows/loops の処理方法に違いがあることがわかります。

SQL1: Seq Scan on bar b (cost=0.00..115.84 rows=5184 width=36) (actual time=0.013..1.626 rows=10000 loops=1)
SQL2: Seq Scan on bar b (cost=0.00..189.00 rows=1 width=5) (actual time=1.015..1.990 rows=1 loops=10000)

この時のSQL1,SQL2のシステムコールを眺めてみよう

SQL1 のシステムコールの統計情報

$ strace -p 28495 -c
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           write
  0.00    0.000000           0        10           lseek
  0.00    0.000000           0         7           brk
  0.00    0.000000           0         4           sendto
  0.00    0.000000           0         6         3 recvfrom
  0.00    0.000000           0         3           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    33         3 total

SQL2 のシステムコールの統計情報

$ strace -p 28495 -c
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           write
  0.00    0.000000           0     10009           lseek
  0.00    0.000000           0         4           sendto
  0.00    0.000000           0         6         3 recvfrom
  0.00    0.000000           0         3           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                 10025         3 total

上記より、SQL1のlseekが10回呼ばれているのに対して、SQL2の lseek は10009回呼ばれていることがわかります。
lseek はファイルの読み書きを行うためにポインタを移動する際に呼ばれるシステムコール。
先のexplain analyzeの結果を合わせてみると、SQL1 SQL2で実行したシーケンシャルスキャンの処理の違いは以下のように推測することができます。

  • SQL1: foo テーブルに対し、bar テーブルを1回のHDDアクセスで10000レコード取得。それを1回のループで処理。
  • SQL2:foo テーブルに対し、bar テーブルを1レコードずつHDDへアクセスしてデータを取得。それを10000回繰り返し処理。

つまり、SQL2の様に select 句でサブクエリを記述するとそのレコード毎に評価されることがシステムコールからも読み取れることがわかりました。

SQL2のシステムコールを減らしてみる

冒頭のシステムコールの説明で「CPU・メモリ上の計算であればシステムコールは発生しない」と書きました。

先のSQL2では、10000レコードの処理を行う際に1レコードずつファイルディスクリプタへアクセスをしていたが、メモリに乗っていればシステムコールは発生しないはず。検索情報テーブル(foo)とテキスト情報テーブル(bar)が結合するカラムにそれぞれインデックスを作成し試してみます。

create index _idx_foo on foo (id1);
create index _idx_bar on bar (id1);

SQL3 (SQL1 with index)

"Hash Join  (cost=289.00..620.50 rows=10000 width=5) (actual time=5.295..10.963 rows=10000 loops=1)"
"  Hash Cond: (a.id1 = b.id1)"
"  ->  Seq Scan on foo a  (cost=0.00..194.00 rows=10000 width=4) (actual time=0.008..1.429 rows=10000 loops=1)"
"  ->  Hash  (cost=164.00..164.00 rows=10000 width=9) (actual time=5.272..5.272 rows=10000 loops=1)"
"        Buckets: 16384  Batches: 1  Memory Usage: 558kB"
"        ->  Seq Scan on bar b  (cost=0.00..164.00 rows=10000 width=9) (actual time=0.007..2.447 rows=10000 loops=1)"
"Planning time: 0.196 ms"
"Execution time: 11.648 ms"

SQL4 (SQL2 with index)

"Seq Scan on foo a  (cost=0.00..83219.00 rows=10000 width=32) (actual time=0.022..61.605 rows=10000 loops=1)"
"  SubPlan 1"
"    ->  Index Scan using _idx_bar on bar b  (cost=0.29..8.30 rows=1 width=5) (actual time=0.004..0.005 rows=1 loops=10000)"
"          Index Cond: (id1 = a.id1)"
"Planning time: 0.068 ms"
"Execution time: 63.099 ms"

実行プランを Explain analze で SQL4(SQL2 with index) の処理速度を確認すると 63.099 ms と、SQL2の 19946.171 ms から大幅に改善したのがわかります。では、この時のシステムコールを確認してみましょう。

SQL3 (SQL1 with index)のシステムコールの統計情報

$ strace -p 28495 -c
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           write
  0.00    0.000000           0        12           lseek
  0.00    0.000000           0         4           sendto
  0.00    0.000000           0         6         3 recvfrom
  0.00    0.000000           0         3           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    28         3 total

SQL4 (SQL2 with index)のシステムコールの統計情報

$ strace -p 28495 -c
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           write
  0.00    0.000000           0        11           lseek
  0.00    0.000000           0         4           sendto
  0.00    0.000000           0         6         3 recvfrom
  0.00    0.000000           0         3           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    27         3 total

SQL4のシステムコールがSQL1と同等レベルまで改善しているのがわかります。

SQL2が遅かった原因 is 何?

処理速度が遅かったSQL2にインデックスを張ったら性能が改善しました。ある意味当然の結果ではありますが、ここではシステムコールの発生が抑えられていることが確認できています。
SQL2のボトルネックを考えた時、シーケンシャルスキャンのロジックがそもそも遅いのか、それとも、ファイルディスクリプタへのアクセスが頻発することによるI/Oのオーバーヘッドが遅いのか、どちらなのでしょうか。

例えば、後者がボトルネックであれば、HDDをSSDに変えればアプリケーションの修正ではなくH/Wのレイヤーで性能を改善することができる、という検討もできます。
これを切り分けるために、SQL2の実行プランかつシステムコールの発生を抑えるSQLを組み立てて確認してみます。

SQLの準備

ここでは with句を使用して試してみます。with句で作成したデータは同一セッション内でメモリ内保存され後続処理で再利用する特性があり、この特性を生かして、予めメモリ内で展開したデータを元にSQL2と同等の処理が実行するSQL5を作成してみます。

SQL5 (with句使用)

with baz as (
	select id1, text1 
	from bar
)
,fug as (
	select id1
	from foo
)
select (select text1 from  baz b where b.id1 = a.id1) as text1
from fug a;

-- explain analyze
"CTE Scan on fug a  (cost=339.00..2250539.00 rows=10000 width=32) (actual time=4.304..36537.863 rows=10000 loops=1)"
"  CTE baz"
"    ->  Seq Scan on bar  (cost=0.00..155.00 rows=10000 width=9) (actual time=0.015..1.356 rows=10000 loops=1)"
"  CTE fug"
"    ->  Seq Scan on foo  (cost=0.00..184.00 rows=10000 width=4) (actual time=0.020..13.423 rows=10000 loops=1)"
"  SubPlan 3"
"    ->  CTE Scan on baz b  (cost=0.00..225.00 rows=50 width=32) (actual time=1.833..3.648 rows=1 loops=10000)"
"          Filter: (id1 = a.id1)"
"          Rows Removed by Filter: 9999"
"Planning time: 0.078 ms"
"Execution time: 36543.400 ms"

SQL5 のシステムコールの統計情報

strace -p 28495 -c
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         3           write
  0.00    0.000000           0        10           lseek
  0.00    0.000000           0         3           brk
  0.00    0.000000           0         4           sendto
  0.00    0.000000           0         6         3 recvfrom
  0.00    0.000000           0         3           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    29         3 total

システムコールの回数だけ見ると、SQL1、SQL3、SQL4と遜色ないレベルにありますが、SQL2のexplain analyzeの結果と比較すると処理速度が悪化しています。

このことから、このSQLにおけるボトルネックはファイルディスクリプタへのアクセスが頻発していることではなく、実行プランがシーケンシャルスキャンになっており、CPUの演算処理で時間がかかっていることだと判断できます。
例えば、SQL2がボトルネックになった場合、SSDを導入しても改善効果は乏しい、ということが推測できると思います。

  • CTE Scan on fug a (cost=339.00..2250539.00 rows=10000 width=32) (**actual time=4.304..36537.863** rows=10000 loops=1)

さいごに

SQLを通してシステムトレースを眺めてみましが、ファイル・ネットソケットのopen/closeが適切に行われていない、ブラックボックス化されたライブラリ動作が遅かった、などを調査する手法としてシステムコールをトレースするのは有効だったりします。
触ったことが無い方、調査等で困った時にふと思い出して頂ければ幸いです。

この記事を書いた人

澁谷 亮

2012年に中途でフォルシアに入社。