postgresql-pure を開発しました

この記事は Haskell Advent Calendar 2019 の6日目の記事です。


hackage.haskell.org

postgresql-pure は HaskellPostgreSQL ドライバー(クライアントライブラリー)で次のような目標で開発しました。

  • マルチコア環境でのパフォーマンス向上
    • 暗黙のロックを回避する
  • マルチプラットフォーム対応
    • C ライブラリーの libpq への依存をなくして特に Windows でのビルドを容易にする
    • 既存ライブラリーとしては postgres-wire が高速だがそれは Windows をサポートしていない
    • pure Haskell 実装のため Eta などの環境へも移植しやすい可能性がある

使用方法

簡単に使用方法を説明します。

下記のようなテーブルがあるとします。

CREATE TABLE person (
  id serial PRIMARY KEY,
  name varchar(255) NOT NULL
);
INSERT INTO person (name) VALUES ('Ada');

このとき ghci で下記のように実行できます。

> :set -XOverloadedStrings
> :set -XFlexibleContexts
> :set -XDataKinds
> :set -XTypeFamilies
> :set -XTypeApplications
> 
> import Database.PostgreSQL.Pure
> import Data.Default.Class (def)
> import Data.Int (Int32)
> import Data.ByteString (ByteString)
> import Data.Tuple.Only (Only (Only))
> import Data.Tuple.List.Only ()
> import Data.Tuple.Homotuple.Only ()
> 
> conn <- connect def
> preparedStatementProcedure = parse "" "SELECT id, name FROM person WHERE id = $1" Nothing
> portalProcedure <- bind @_ @2 @_ @_ "" BinaryFormat BinaryFormat (parameters conn) (const $ fail "") (Only (1 :: Int32)) preparedStatementProcedure
> executedProcedure = execute @_ @_ @(Int32, ByteString) 0 (const $ fail "") portalProcedure
> ((_, _, e, _), _) <- sync conn executedProcedure
> records e
[(1,"Ada")]

重要な部分を抽出すると parsebindexecute の順に呼びだし、最後に sync でサーバーに送信します。parsebindexecute は入出力のない関数であり、リクエストのビルダーと対応するレスポンスのパーサーを構築しています。そしてそのビルダーとパーサーを sync が使用して送受信を行ないます。bindモナド値を返すようになっているのは失敗する可能性があるためで、型は MonadFail m => m a となっており IO a ではありません。

型適用が所々に明記されていますが、これは ghci で実行しているので結果の使用部分からの推論ができないためで、実際のコードではほとんどの場合で型適用の明記は必要なくなります。

postgresql-pure では、タプルによる要素数の不一致を型検査で検出するインターフェース Database.PostgreSQL.Pure(上記の例)と、しないインターフェース Database.PostgreSQL.Pure.List と、HDBC 互換インターフェース Database.HDBC.PostgreSQL.Pure の3つを提供しています。

高速化

高速化に寄与した技術を説明します。

Haskell

まずは Haskell 一般に関連するものについての技術です。

大きな byte string を何度も確保しない

送信と受信のたびに byte string を確保することを避けました。約 3 kB 以上のメモリーを確保すると暗黙のグローバルロックがかかります1。送受信のたびに確保するのをやめ、代わりにバッファとして確保した領域を何度も再利用するようにしました。手動によるメモリー管理のために bytestring パッケージの Data.ByteString.Internal.mallocByteString と network パッケージの Network.Socket.sendBufrecvBuf を使用しました。

mallocByteString :: Int -> IO (ForeignPtr a)
sendBuf :: Socket -> Ptr Word8 -> Int -> IO Int
recvBuf :: Socket -> Ptr Word8 -> Int -> IO Int

接続時に mallocByteString で2つのバッファを確保します。送信するメッセージは Data.ByteString.Builder.Extra.BufferWriter によって構築し、受信するメッセージは Data.Attoparsec.parseWith でパースします。

シンボルには ShortByteString を使用する

LISP のシンボルのような短い文字列には ShortByteString を使用しましょう。ShortByteStringByteString よりもオーバーヘッドが少なく、またヒープフラグメンテーションを引き起こしません。ShortByteString には lengthindex のような簡単な操作だけが提供され複雑な操作は提供されていません。このライブラリーではサーバーのパラメーターを保存するために使用しました。

PostgreSQL

次に PostgreSQL 固有の効率化について説明します。

PostgreSQL プロトコルには2つの問い合わせ方法があります。ひとつは簡易問い合わせで、もうひとつは拡張問い合わせです。簡易問い合わせには最小限の機能しかなくプリペアドステートメントやバイナリーフォーマット、結果を1レコードずつフェッチすることなどはサポートされていないので、このライブラリーでは拡張問い合わせを採用しました。

PostgreSQL プロトコルTCP の上で動作し、複数のメッセージは結合してひとつの TCP ペイロードに格納することができます。

下記の図はメッセージをひとつずつ送信した場合を表しています。

f:id:kakkun61:20191203182127p:plain
メッセージをひとつずつ送信した場合

メッセージを結合した場合は下記のようになります。

f:id:kakkun61:20191203182234p:plain
メッセージを結合した場合

メッセージを結合することで送受信するデータ量を減らし、またシステムコールの回数も減らすことができます。

既存のドライバー

ベンチマーク

下記のような単純な定数値のみの問い合わせを秒間何回問い合わせられるかを計測しました。定数値を使用したのはサーバーがボトルネックにならないためです。

SELECT 2147483647 :: int4, 9223372036854775807 :: int8, 1234567890.0123456789 :: numeric, 0.015625 :: float4, 0.00024414062 :: float8, 'hello' :: varchar, 'hello' :: text, '\xDEADBEEF' :: bytea, '1000-01-01 00:00:00.000001' :: timestamp, '2000-01-01 00:00:00.000001+14:30' :: timestamptz, '0001-01-01' :: date, '23:00:00' :: time, true :: bool;

環境は下記の通りです。

計測結果を下に示します。縦軸は秒間リクエスト数で横軸はスレッド数です。

このライブラリーも postgres-wire も約4スレッドまではほぼ同じパフォーマスです。それ以上になると postgres-wire が線形比例を下回っていくのに対し、このライブラリーはより線形に近くなっています。

(比較対象は開発途中でのベンチマークにおいておそかったものを除去しています。)

f:id:kakkun61:20191203182308p:plain
ベンチマーク結果


postgresql-pure は IIJ イノベーションインスティテュートの業務として作成されました。