[sqlite-zstd] SQLiteのextensionをつかって透過的なデータの圧縮

2022.09.16

Introduction

SQLiteはWASMからクラウドのエッジRDBまで、
システムの規模を問わずさまざまな場所で動いているRDBMSです。

軽量でコンパクトなのが特徴でとても使いやすいのですが、
機能は他のRDBと比較してそこまで豊富ではなく、OracleやMySQLと比較すると
組み込み関数はほとんどないです。
その機能のひとつがデータ圧縮で、SQLiteには圧縮機能がありません。

なので、データが大きくなってきた場合はSQLiteのデータベースファイル自体を圧縮するとか
VACUUMでスペースを開放するくらいしかないかと思われます。
しかし、ファイル圧縮した場合、そのままデータにアクセスすることはできません。

そんな課題のあるSQLiteでのデータ圧縮ですが、
今回紹介するsqlite-zstdを使えば、透過的な圧縮を実行できます。

sqlite-zstd?

sqlite-zstdはSQLiteのデータベースを圧縮して、検索機能をそのまま使えつつ、
サイズを大幅に削減するRust製のCrate・SQLiteの拡張ライブラリです。

さきほどもいったように、SQLiteのデータベースファイル全体を圧縮すると
サイズが約90%程度縮小されますが、その方法ではデータをそのままクエリできません。
また、データベースへ保存する前にアプリコード内のデータを圧縮すると、
サイズ圧縮は23%程度にしかならないようです。

sqlite-zstdは透過的な行レベルの圧縮を行い、アプリのコードを変更しなくても、
通常のクエリ機能を使いつつサイズを80%程度(データの種類によっては)
削減することができるとのことです。

sqlite-zstd開発者(phiresky氏)のblogでは、
どういった経緯でsqlite-zstdを開発したかが述べられています。

以前、phiresky氏は分析用にAndroidデバイスの情報を収集するツールを開発していて、
そのSQLiteデータが巨大になるのを小さくしたいという動機から開発したようです。

ここを見ると、
「データ自体は頻繁に変化するものではないが、
後で集計するために詳細なスナップショットを保存したい」
といったユースケースで高い効果を発揮するみたいです。

SQLite Extension?

SQLiteでは、共有ライブラリ (.soとかのやつ)を拡張ライブラリとして使うことが可能です。
プログラム内では各種SQLite用クライアントライブラリで実装している、
enable_load_extensionとかload_extensionの処理を実行すれば
拡張ライブラリを使うことが可能です。

↓ではSQLiteのCLIで拡張関数をインストールしている例です。

$ sqlite3 mydb.db

Enter ".help" for usage hints.
sqlite> SELECT ext_func("hi"); #デフォルトではext_func関数は使えない
Error: no such function: ext_func

sqlite> .load ./libsqlitefunctions.so #ext_funcを定義した拡張ライブラリをロード
sqlite> SELECT ext_func("hi"); #関数が使えるようになる
・・・

Environment

  • OS :  Ubuntu-20.04.5
  • SQLite3 : 3.31.1
  • Rust : 1.63.0

※UbuntuはM1 macからUTMつかって起動

Setup

では、UbuntuにsshログインしてRustとSQLite3の環境をセットアップしましょう。

まずはRustのインストールをします。

% sudo apt install gcc

% curl https://sh.rustup.rs -sSf | sh
% echo export PATH="$HOME/.cargo/bin:$PATH" >> ~/.bash_profile
% source ~/.bashrc

SQLite3とsqlite-zstdのビルドをします。

% sudo apt install sqlite3
% git clone https://github.com/phiresky/sqlite-zstd.git
% cd sqlite-zstd/

#build
% cargo build --release --features build_extension

% ls target/release/
libsqlite_zstd.so

SQLiteの拡張ライブラリであるlibsqlite_zstd.soが生成されたので、
ロードしてみましょう。

% sqlite3 cli.db

SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.

#拡張ライブラリをロード(パスは自分の環境を使用)
sqlite> .load /path/your/libsqlite_zstd.so
[2022-09-15T03:05:33Z INFO  sqlite_zstd::create_extension] [sqlite-zstd] initialized

上記のようになればOKです。
sqlite-zstdは使える状態になっています。

Try

では、SQLiteのデータを登録して圧縮してから
Rust(Rusqlite)で検索してみましょう。

SQLite3に適当なデータ登録

まずはSQLiteにlogsテーブルを作成し、データを登録します。
テーブル定義はidとJSONデータを登録するdataフィールドのみです。

% sqlite3 cli.db

sqlite> create table if not exists logs (
            id integer primary key,
            data text not null
);

スクリプトでもプログラムでもいいので、データを10万件くらい登録します。
Rustでやるには↓のような感じで実装します。

#Cargo.toml
[dependencies.rusqlite]
version = "0.28.0"
features = ["bundled","load_extension"]
//main.rs
use rusqlite::{params, Connection, Result,LoadExtensionGuard};

#[derive(Debug)]
struct Logs{
    id: u32,
    data:String
}

fn open_my_db(file:&str) -> Result<Connection,rusqlite::Error> {
    let con = Connection::open(&file)?;
    println!("{}", con.is_autocommit());
    Ok(con)
}

fn insert(con:&Connection,log:&Logs) -> Result<usize,rusqlite::Error> {
  return Ok(con.execute(
      "insert into logs (data) values (?1)",
      params![log.data]
  )?);
}

static json:&str = r#"{適当なJSONデータ}"#;

fn insert_datas(con:&Connection,count:u32) {
    let mut i:u32 = 1;
    while i < count {
      let log = Logs{
        id:i,
        data:json.to_string()
      };
      insert(&con,&log);
      i += 1;
    }
}

fn main() {
  let con = open_my_db("./test.db").unwrap();
  insert_datas(&con,100000);
}

実際にデータを10万件登録したところ、
サイズは136876032byte(約136MB)でした。

sqlite-zstdでデータ圧縮

sqlite-zstdをつかってlogsテーブルのデータを圧縮してみます。
CLIを起動して拡張ライブラリをロード後、
zstd_enable_transparent関数をつかって圧縮対象を指定します。
その後、zstd_incremental_maintenance関数を呼ぶことで実際に圧縮を実施します。

% sqlite3 test.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.

#拡張ライブラリをロード(パスは自分の環境を使用)
sqlite> .load /path/your/libsqlite_zstd.so

sqlite> select zstd_enable_transparent('{"table": "logs",
    "column": "data", "compression_level": 19,
    "dict_chooser": "''i.'' || (id / 1000000)"}');

sqlite> select zstd_incremental_maintenance(null, 1);
sqlite> vacuum;

最後にvacuum関数でディスクスペース開放します。
実際に圧縮を実行したところ、10301376byte(約10M)になりました。
※片寄ったデータなので圧縮率がめちゃくちゃ高い

blogでは2GBのファイルが500MBくらいまで圧縮されてます。
具体的にどういう仕組みで動いているかは、
ここに書いてあるのでご確認ください。

データに対してクエリ実行してみる

では、圧縮されたデータベースに対してRustプログラムからクエリを実行してみます。
Cargoでプロジェクト作成。

% cargo new rust-sqlite-zstd && cd rust-sqlite-zstd

Cargo.tomlに依存ライブラリを記述。
load_extensionのfeatureを指定しているところに注意。

[dependencies.rusqlite]
version = "0.28.0"
features = ["bundled","load_extension"]
use rusqlite::{params, Connection, Result,LoadExtensionGuard};
use std::path::Path;

#[derive(Debug)]
struct Logs{
    id: u32,
    data:String
}

fn open_my_db(file:&str) -> Result<Connection,rusqlite::Error> {
    let con = Connection::open(&file)?;
    println!("{}", con.is_autocommit());
    Ok(con)
}

fn load_my_extension(con: &Connection) -> Result<()> {
    unsafe {
      let _guard = LoadExtensionGuard::new(con)?;
      con.load_extension(Path::new("/path/your/libsqlite_zstd.so"), None)
    }
}

fn select_all(con:&Connection){
    let mut stmt = con.prepare("select id,data from logs limit 2").unwrap();
    let logs = stmt.query_map(params![], |row| {
      Ok(Logs {
          id: row.get(0).unwrap(),
          data: row.get(1).unwrap()
      })
    }).unwrap();

    for log in logs {
      println!("{:?}", log.unwrap());
    }
}

fn main() {
  let con = open_my_db("./test.db").unwrap();
  load_my_extension(&con);
  select_all(&con);
}

load_my_extension関数で拡張ライブラリをロードします。
unsafeブロックをつかってload_extensionで
libsqlite_zstd.soを指定します。
こうしておけば、そのままのselect文でデータ検索できます。
※CLIでもloadしておけばそのままselectできる

実際にcargo runで実行してみてselect結果が
普通に取得できるのを確認してみてください。

ちなみに、sqlite_zstd crateを使うと、
↓のようにConnectionをsqlite_zstd::loadにわたすだけでよいみたいなのですが、
私の場合はビルド時にlibsqlite3-sys関連のエラーがでて無理でした。

let con: rusqlite::Connection;
sqlite_zstd::load(&con)?;

Summary

今回はSQLiteの圧縮ライブラリのsqlite-zstdを試してみました。
どういったタイミングで圧縮するかなど検討する必要はありますが、
圧縮率も高く、コード変更なしでクエリも投げられるので便利です。
なお、理論上は同じアルゴリズムでPostgreSQLでもいけるらしいです。

References