玩式草子─ソフトウェアとたわむれる日々

第33回SQLiteでRDB再入門その3]

この話題を取り上げ始めたのは、2月末のまだまだ寒い時期でしたが、あっと言う間に季節は巡り、桜の花も過ぎて、初夏の陽気すら感じる季節になりました。

過去2回では、依存関係情報やそれをSQLiteを使ってデータベースする際の仕組みなどを実際のスクリプトを紹介しながら説明してきました。今回はこの話題のまとめとして、説明してきたスクリプトを実際に使う際に生じた問題点やスクリプトの使用例などを紹介してみます。

依存関係情報収集スクリプトとSQLiteの罠

前回作成した2つのスクリプトをまとめて、⁠依存関係情報を集めるスクリプト」の意味でget_depends.pyという名称にしてみました。

さっそく、このスクリプトで手元の環境の依存関係情報のデータベース化を試したところ、処理にずいぶん時間がかかります。依存関係情報の検索範囲をファイルシステムの一部に限ってみても、検索、登録作業に3~4時間はかかるようでした。しかも、その時間のほとんどを費しているのがSQLiteのデータベースに依存関係を登録する処理で、出力されるメッセージを見る限り、前回紹介した1レコード4カラムの単純なデータすら、1秒あたり数件程度しか登録できないようです。

気軽に使えるのが特徴だとしても、データベースシステムの本質とも言えるデータの登録作業にこんなに時間がかかるようでは、とても実用的なシステムには使えないなぁ…、と失望しかけましたが、Google等で調べてみると、SQLiteのデータ登録(インサート)は、インサートごとにトランザクション処理が行われるので時間がかかる、といった情報が多数見つかりました。

この問題はSQLiteのホームページのFAQにも言及されていました。

[原文]

(19) INSERT is really slow - I can only do few dozen INSERTs per second

Actually, SQLite will easily do 50,000 or more INSERT statements per second on an average desktop computer. But it will only do a few dozen transactions per second. Transaction speed is limited by the rotational speed of your disk drive. A transaction normally requires two complete rotations of the disk platter, which on a 7200RPM disk drive limits you to about 60 transactions per second.

Transaction speed is limited by disk drive speed because (by default) SQLite actually waits until the data really is safely stored on the disk surface before the transaction is complete. That way, if you suddenly lose power or if your OS crashes, your data is still safe. For details, read about atomic commit in SQLite..

By default, each INSERT statement is its own transaction. But if you surround multiple INSERT statements with BEGIN...COMMIT then all the inserts are grouped into a single transaction. The time needed to commit the transaction is amortized over all the enclosed insert statements and so the time per insert statement is greatly reduced.

[拙訳]

(19)インサートがきわめて遅い - 1秒あたり数十件のインサートしか行えない

実のところ、SQLiteは、平均的なデスクトップPC上で50,000件/秒以上のインサートを容易にこなしますが、トランザクション処理が1秒あたり数十件しかできないのです。トランザクションの速度はHDDの回転速度に依存します。トランザクションには、通常、HDDの記録面が2周する必要があります。そのため7200RPMのHDDでは(1分あたり7200回転=1秒あたり120回転なので)1秒あたりに可能なトランザクションの回数は60回に制限されます。

トランザクションの速度がディスクドライブの速度に制限されるのは、SQLiteは(通常では)データが正しくディスク上に書き込まれたことを確認するまで、処理の完了を待つためです。こうしておけば、突然電源が切れたり、OSがクラッシュしたとしてもデータは安全ですから。詳細については、SQLiteのatomic commitについて調べてください。

デフォルトの設定では、インサートはそれぞれがトランザクション処理になります。しかし、複数のインサートをBEGIN...COMMITで囲めば、その中のインサートは1つのトランザクションにまとめられ、その結果、各インサートにかかる時間を大幅に改善することが可能です。

なるほど、SQLiteの場合、デフォルトでは1つのインサートが1つのトランザクションと扱われるので、大量のデータを1つずつインサートしようとすると長い時間がかかってしまうようです。

上記FAQによると、複数のINSERT文をBEGIN...COMMITで囲めばいいそうですが、そのような処理はPythonではどうするのだろう…、と思ってSQLite3モジュールのドキュメントを読み返してみると、executemany()という関数が用意されていました。この関数を使えばタプルのリストに対してまとめてSQL文を実行することができそうです。

そこで、SQLiteのデータベースに登録するinsert_db()の部分を、executemany()を使うように修正したinsert_db2()を用意しました。

リスト1 insert_db2()
def insert_db2(dbname, t):
    conn = sqlite3.connect(dbname)
    try:
        print "inserting ", t
        conn.executemany('insert into depends values(?, ?, ?, ?)', t)
        conn.commit()
    except sqlite3.Error, e:
        print "An error occurred:", e.args[0]
        conn.rollback()

insert_db2()は、従来のinsert_db()のように1つのタプルごとにインサートを実行するのではなく、複数のタプルを集めたリストを受け取って一気にインサートするので、この関数を呼び出す部分も登録すべきデータ(タプル)をいったんリストに集めた上で渡すように修正しました。

リスト2 insert_db2()の使い方
    list = []
    for file in files:
        base = os.path.basename(file)
        tmp = get_depends(file)
        for i in tmp:
            (soname, realname) = split_parts(i)
            print("{0}, {1}, {2}, {3}".format(base, file, soname, realname))
            t = (base, file, soname, realname)
            # insert_db(dbname, t)
            list.append(t)

    insert_db2(dbname, list)

この改造でどれくらい処理速度が向上するかを確認するために、調査範囲を/usr/bin以下に限定してtimeコマンド経由で実行したところ、表1のような結果になりました。ざっと見で、38分かかっていた処理が30秒弱にまで改善したようです。

表1 insert_db2()の効果
 1つずつinsertまとめてinsert
実時間(real)38m4.179s0m27.210s
ユーザ時間(user)0m13.019s0m5.773s
システム時間(sys)0m19.659s0m5.293s

登録処理が終了したデータベースを調べると、22,524件のデータが登録されていましたので、この結果から計算すると、1つずつインサートした場合は1秒あたり9.8件まとめてインサートした場合は1秒あたり828件登録できたことになります。

この結果を見ると、FAQにもあるように、SQLiteではインサート時のトランザクション処理が律速段階になっていて、データをまとめてインサートすることが処理速度を向上させる鍵になっていることがわかります。このあたりは、同じRDBソフトウェアでもPostgreSQLやMySQLには無かった特徴で、SQLiteを利用する際には注意すべきポイントになるでしょう。

一方、get_depends.pyスクリプトのうち、指定したディレクトリ以下からELF形式のバイナリファイルを探す処理についても、/usr/include/や/usr/share/等、バイナリファイルが無いことがわかっているディレクトリはあらかじめ検索対象から省くようにして、検索速度の向上を目指しました。

これらの改造の結果、当初は5~6時間以上かかっていた、22万件のデータを収めた依存関係情報データベースの作成が、5分前後で終了するようになりました。これくらいの速度ならば、開発作業の合間に最新の情報に更新することも容易でしょう。

このスクリプトの完成版は筆者の日記のページに添付ファイルとして置いています。同じようなコードを再掲するのも何なので、興味ある人は上記サイトからご入手ください。

依存関係情報検索スクリプト

依存関係情報のデータベースができたので、次はこのデータベースを検索するツールを書いてみます。

前回紹介したように、作成した依存関係情報のデータベースは4つの欄からなる表形式になっています。

表2 ライブラリ依存関係DBのテーブル
コマンド名ファイル名共有ライブラリ名ライブラリへのパス
sed/usr/bin/sedlinux-vdso.so.1 
sed/usr/bin/sedlibacl.so.1/usr/lib64/libacl.so.1
sed/usr/bin/sedlibc.so.6/lib64/lib.so.6
sed/usr/bin/sedlibattr.so.1/usr/lib64/libattr.so.1
sed/usr/bin/sed/lib64/ld-linux-x86-64.so.2

検索ツールでは、これら4つの欄のどれかをキーとして他の欄を検索することになりますので、どの欄をキーにするかはオプションで指定できるようにしましょう。

検索の方向は、コマンド名やファイル名から共有ライブラリ名を引く場合と、共有ライブラリ名からコマンド名やファイル名を引く場合があります。以下では、説明のために前者の方向を正引き⁠、後者を逆引きと呼ぶことにします。

前回紹介したように、作成したデータベース中では、各欄は表3のような名称になっているので、それぞれの頭文字をオプション指定に使って、-bがコマンド名からの正引き、-pがファイル名からの正引き、-sが共有ライブラリ名からの逆引き、-rがライブラリへのパスからの逆引き、ということにしましょう。

表3 依存関係DBのキー名称
コマンド名ファイル名共有ライブラリ名ライブラリへのパス
basepathsonamerealname

Pythonには、UNIXのgetopt同様の機能を実現するgetoptというモジュールが用意されているので、そのモジュールをインポートしてやれば、オプションをパースする部分はリスト3のような形で書けるでしょう。

リスト3
 1    import getopt
 2 
 3    def get_opts():
 4        try:
 5            opts, args = getopt.gnu_getopt(sys.argv[1:], "b:p:s:r:", ["base=","path=","soname=", "realname="])
 6        except getopt.GetoptError:
 7            usage()
 8            sys.exit(2)
 9    
10        for o, a in opts:
11            if o in ("-b", "--base"):
12                cmd = 'base'
13                arg = a
14            elif o in ("-p", "--path"):
15                cmd = 'path'
16                arg = a
17            elif o in ("-s", "--soname"):
18                cmd = 'soname'
19                arg = a
20            elif o in ("-r", "--realname"):
21                cmd  = 'realname'
22                arg = a
23            else:
24                assert False, "unhundled option"
25                usage()
26        
27        return (cmd, arg)

5行目が引数として指定されたオプションと検索の際のキーワードになるファイル名解析する部分で、getopt.gnu_getopt()を使って、UNIXで伝統的なハイフン+1文字で指定する-b catといったオプション指定と、長形式と呼ばれる--base=catといった指定の双方を利用可能にしています。

10行目からは、解析した引数を元に、cmdにデータベースを引く際にキーとする欄を、argにキーワードを収めて返しています。

データベースを検索する部分はリスト4のようにしてみました。このquery()という関数は、データベース名(db)と検索する欄(cmd⁠⁠、キーワード(arg)を引数にとって、検索した結果を表示します。

リスト4
 1    import sqlite3
 2    
 3    def query(db, cmd, arg):
 4        conn = sqlite3.connect(db)
 5        cur = conn.cursor()
 6        sql = 'SELECT {0} FROM depends WHERE {0} LIKE "%{1}%" GROUP BY {0};'.format(cmd, arg)
 7        cur.execute(sql)
 8        tgt = []
 9        for i in cur:
10            tgt.append(i[0])
11    
12        for i in tgt:
13            if cmd == 'base' or cmd == 'path' :
14                print("{0} needs these libraries".format(i))
15            else:
16                print("{0} used by these binaries".format(i))
17    
18            sql = 'SELECT * FROM depends WHERE {0}="{1}";'.format(cmd, i)
19            cur.execute(sql)
20            for row in cur:
21                (base, path, soname, realname) = row
22                if cmd == 'base' or cmd == 'path' :
23                    print("  {0}({1})".format(soname, realname))
24                else:
25                    print("  {0}({1})".format(base, path))
26            print

この関数では6行目と18行目の2箇所でSQL文を生成しています。6行目のSQL文では、任意の文字列にマッチするSQLのワイルドカードキャラクタである"%"をキーワードの前後に追加して、検索対象とした欄にキーワードに指定した文字列の一部があればマッチするあいまい検索を行っています。

たとえば、cmdにsoname、argにlibcupsを指定すると、

SELECT soname FROM depends WHERE soname LIKE "%libcups%" GROUP BY soname;

といったSQL文に展開されるわけです。

sqlite3コマンドを使って実際にこのSQL文で検索してみると、以下のようにlibcupsを一部に含む共有ライブラリ名が複数得られます。

$ sqlite3 depends.sql3
SQLite version 3.7.10 2012-01-16 13:28:40
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> SELECT soname FROM depends WHERE soname LIKE "%libcups%" GROUP BY soname;
libcups.so.2
libcupscgi.so.1
libcupsdriver.so.1
libcupsimage.so.2
libcupsmime.so.1
libcupsppdc.so.1
sqlite>

この結果を、いったんtgt[]というリストに収めておいて(10行目⁠⁠、結果の各行を用いて18行目で以下のようなSQL文を生成します。

SELECT * FROM depends WHERE soname="libcups.so.2";

このSQL文で再度データベースを検索すれば、libcups.so.2を使っているバイナリファイルの一覧が得られます。

sqlite> SELECT * FROM depends WHERE soname="libcups.so.2";
epdfview|/usr/bin/epdfview|libcups.so.2|/usr/lib64/libcups.so.2
gs|/usr/bin/gs|libcups.so.2|/usr/lib64/libcups.so.2
gsc|/usr/bin/gsc|libcups.so.2|/usr/lib64/libcups.so.2
gsx|/usr/bin/gsx|libcups.so.2|/usr/lib64/libcups.so.2
....

この結果を21行目から25行目で整形して表示しているわけです。

実際のスクリプトではこれら以外にも、main()の処理やusage()の使用法表示等がありますが、煩雑になりそうなので割愛します。このスクリプトの完成版も、get_depends.py同様、筆者の日記のページに添付ファイルとして置いていますので、興味ある人は上記サイトからご入手ください。

スクリプトの活用例

依存関係情報を収集するスクリプトをget_depends.pyとしたので、前節で紹介した検索するスクリプトはquery_depends.pyと呼ぶことにします。

まず正引きの例として、-bオプションを使えば、バイナリファイル名の一部から、そのバイナリファイルが参照している共有ライブラリを調べることができます。

$ ./query_depends.py -b cat
bdftruncate needs these libraries
  linux-vdso.so.1(none)
  libc.so.6(/lib64/libc.so.6)
  /lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)

cat needs these libraries
  linux-vdso.so.1(none)
  libc.so.6(/lib64/libc.so.6)
  /lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)

catia.so needs these libraries
  linux-vdso.so.1(none)
  libc.so.6(/lib64/libc.so.6)
  /lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)
...

ファイル名の一部からだとマッチする件数が多すぎる場合は、フルパス名で指定すれば検索対象が一意に定まります。

$ ./query_depends.py -p /bin/cat
/bin/cat needs these libraries
  linux-vdso.so.1(none)
  libc.so.6(/lib64/libc.so.6)
  /lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)

共有ライブラリ名の一部から逆引きするには-s オプションを指定します。

$ ./query_depends.py -s poppler
libpoppler-glib.so.5 used by these binaries
  inkscape(/usr/bin/inkscape)
  inkview(/usr/bin/inkview)
  poppler.so(/usr/lib64/python2.7/site-packages/poppler.so)
  libpdfdocument.so(/usr/lib64/evince/3/backends/libpdfdocument.so)
  tumbler-poppler-thumbnailer.so(/usr/lib64/tumbler-1/plugins/tumbler-poppler-thumbnailer.so)

libpoppler-glib.so.8 used by these binaries
  epdfview(/usr/bin/epdfview)
  poppler-glib-demo(/usr/bin/poppler-glib-demo)
  file-pdf(/usr/lib64/gimp/2.0/plug-ins/file-pdf)
...

これは当初考えていなかった機能ですが、共有ライブラリのパス名として"found"という文字列を指定すれば、⁠lddが"not found"というメッセージを出している)見つからなかった共有ライブラリを参照しているバイナリファイルを調べることもできます。

$ ./query_depends.py -r found
not found used by these binaries
  m_xt.so(/lib/tc/m_xt.so)
  libwx_gtk2u_html-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_html-2.8.so.0.8.0)
  libwx_gtk2u_xrc-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_xrc-2.8.so.0.8.0)
  libwx_gtk2u_richtext-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_richtext-2.8.so.0.8.0)
  libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
  libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
  libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
  libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
  ....

どんな共有ライブラリが見つからなかったかを調べるには、-bオプションでそのバイナリファイルが参照している共有ライブラリを調べます。

$ ./query_depends.py -b libboost_graph.so.1.45.0
libboost_graph.so.1.45.0 needs these libraries
  linux-vdso.so.1(none)
  libboost_regex.so.1.45.0(/usr/lib64/libboost_regex.so.1.45.0)
  libicuuc.so.46(not found)
  libicui18n.so.46(not found)
  ...

あれれ、C/C++/Java用のUnicodeライブラリであるlibicuは入れてるはずなのに見つからない、と言われてしまいました。おかしいな、と思って、libicuuc.so を検索してみると、

./query_depends.py -b libicuuc.so
libicuuc.so.44.2 needs these libraries
  linux-vdso.so.1(none)
  libicudata.so.44(/opt/libreoffice3.4/basis3.4/program/libicudata.so.44)
  libpthread.so.0(/lib64/libpthread.so.0)
...
libicuuc.so.48.1.1 needs these libraries
  linux-vdso.so.1(none)
  libicudata.so.48(/usr/lib64/libicudata.so.48)
  libpthread.so.0(/lib64/libpthread.so.0)
...

libicuuc.soの44.248.1.1はあるのに、46というバージョンは存在しないようです。これらはどのパッケージでインストールされたのだろう、と/var/log/packages/以下を調べると、icuパッケージlibobasis3.4_core05パッケージに由来することがわかりました。

$ grep libicuuc /var/log/packages/*
 /var/log/packages/icu:usr/lib64/libicuuc.so.48.1.1
 /var/log/packages/libobasis3.4_core05:opt/libreoffice3.4/basis3.4/program/libicuuc.so.44.2

このうち、libobasis3.4_core05はLibreOfficeの一部なので、システム全体で使っているのはicuパッケージのはずです。この結果を見ると、どうやらicuパッケージを更新した後で、このパッケージを使っているC++用のライブラリ集であるBoostをビルドし忘れていたようです。

参照が解決できない共有ライブラリ(たとえばlibboost_graph.so.1.45.0)を使っているバイナリファイルは起動できなくなっているはずなので、あわてて、この共有ライブラリを使っているコマンド類を調べてみると、

$ ./query_depends.py -s libboost_graph
$

何のことはない、該当するファイルはありませんでした……。orz

さて、そもそもこのパッケージは必要なのか? という疑問も生じてきますが、Boostライブラリの有無をチェックしているソフトウェアがあったからこのパッケージを作ったはずなので、いずれにしても早めに更新しておくのがよさそうです。


本連載では3回に渡ってSQLiteを用いた依存関係管理システムを紹介してみました。

実のところ、SQLiteを用いたスクリプトを作るのは初めてだったので、あちこちにたどたどしい部分があったかと思いますが、SQLiteの面白さや便利さは多少なりとも伝わったでしょうか?

今回紹介したスクリプトを使えば、22万件を越える依存関係情報の中から、コンマ数秒で指定した関係を検索できるようになり、ライブラリ類を更新する順番を考えるのがずいぶん楽になりました。

テキストベースでは管理しづらい規模だけど、わざわざPostgreSQLやMySQLを入れるのも面倒くさい、といった規模のデータを扱うには、SQLiteは便利な選択肢になりそうです。

おすすめ記事

記事・ニュース一覧