VNCTST games 開発日誌

ゲーム開発日誌

mapdb導入

先日に「clojure向けの組み込みデータベースの選定」を行い、「mapdbを使う」という結論となった。そして「2016年2月後期分の試作ゲームを公開」にて実際に使ってみた。そのまとめ。

まとめ

  • 非常に使いやすい。これはおすすめ。もっと使われるべき。
  • ライセンスはapache2
  • 現在も開発/メンテが続いている。
  • 並行処理の隔離を自分で適切に行う必要のあるタイプ。
  • パフォーマンスは未測定。しかしそもそもパフォーマンスを求めるタイプのものではない。
  • まだ運用期間が短い為、耐障害性についても不明。こちらはかなり気になる…。

セットアップ

  • mavenに登録されているので、セットアップは:dependencies[org.mapdb/mapdb "X.Y.Z"]を追加するだけでよい

  • バージョン選択

    • 現時点では、安定版である 1.0.9 と、現在も開発が進んでいる 3.0.0-M2 とがあった
      • どっちにするか非常に迷うが、パフォーマンスや機能の充実よりも不具合の少なさを期待し、今回は1.0.9を選択した。

使い方

最初は文章で説明を書いていたが、分かりづらすぎたのでコードにする事にした。省略した部分もあるが大体こんな感じ

(ns hoge.db
  (:refer-clojure :exclude [get set!])
  (:require [clojure.java.io :as io]
            [clojure.edn :as edn])
  (:import [org.mapdb DB DBMaker]
           [java.util Map]))

(defonce the-locker (Object.))
(defmacro with-lock [& bodies]
  `(locking the-locker ~@bodies))

(defonce db-entity (atom nil))
(defonce ^:dynamic ^DB db nil)

(defmacro with-db [& bodies]
  `(binding [db @db-entity]
     (when db
       ~@bodies)))

;;; サイズを小さくする為に、core.async等で定期的に実行する必要がある
;;; (postgresでのvacuum相当だと思われる)
(defn compact! []
  (with-db
    (.compact db)))

;;; 使う前にこれを呼ぶ
(defn init! [path & [password]]
  (with-lock
    (when-not @db-entity
      (let [db-maker (DBMaker/newFileDB (io/file path))]
        (.closeOnJvmShutdown db-maker)
        (.compressionEnable db-maker)
        ;(.transactionDisable db-maker) ; transactionは必須(後述)
        (when password
          (.encryptionEnable db-maker password))
        (let [db (.make db-maker)]
          (reset! db-entity db))))))

;;; 使い終わったらこれを呼ぶ
;;; (サーバ用途等で、プロセス終了時までずっと使い続ける場合は、
;;; .closeOnJvmShutdown しているので、呼ばなくてよい)
(defn term! []
  (with-lock
    (with-db
      (when-not (.isClosed db)
        (try
          (.close db)
          (catch Throwable e nil)))
      (reset! db-entity nil))))

;;; テーブル取得。実際の利用時には適切にキャッシュすべき
(defn get-table [k]
  (with-lock
    (with-db
      (.getHashMap db (name k)))))

(defn get [^Map table k]
  (with-lock
    (with-db
      ;; TODO: 実際はリードエラー対応等を入れる事
      (edn/read-string (.get table (name k))))))

(defn set! [^Map table k v]
  (with-lock
    (with-db
      ;; TODO: 実際はシリアライズ不可なものが含まれてないか等の確認が必要
      (.put table (name k) (pr-str v))
      (.commit db) ; ※後述
      )))

(defn update! [^Map table k f]
  (with-lock
    (with-db
      ;; TODO: getおよびset!と同じ問題に注意
      (let [old-v (edn/read-string (.get table (name k)))
            new-v (f old-v)]
        (.put table (name k) (pr-str v))
        (.commit db) ; ※後述
        new-v))))



(comment
  (require '[hoge.db :as db])
  (db/init! "path/to/db.dat")
  (def table (db/get-table :table-name))
  (db/get table :hoge) ; => nil
  (db/set! table :hoge [1 :a 2])
  (db/get table :hoge) ; => [1 :a 2]
  (db/set! table :hoge 3)
  (db/update! table :hoge inc)
  (db/get table :hoge) ; => 4
  ;; 他スレッドとの競合(不整合)が起こらない事を保証しつつ、
  ;; 二つのエントリを参照した結果をテーブルに保存する
  (db/with-lock
    (let [a1 (or (db/get table :fuga) 0)
          a2 (or (db/get table :hoge) 0)
          result (+ a1 a2)]
      (db/set! table :fuga result))))

解説

  • 重要な事は大体 http://www.mapdb.org/doc/getting-started.html に書いてある。これだけ読めば基本的には問題はない

  • (DBMaker/newFileDB (io/file path)) で、DBMakerインスタンスを生成。引数は保存/読み込みを行うファイル。

    • 引数に指定したファイルが hoge.dat だった場合、 hoge.dat hoge.dat.p hoge.dat.t の三つのファイルが作られる。よく分からないが、そういうもののようだ。
    • 「一つのデータファイルを開くのは一つのプロセスに限る事。複数のプロセスから同時に開いたらcorruptする」と上記urlに書いてある。なので、呼び出し元でこの事を保証しておいた方がよい(多重起動防止コードを入れる等)
  • 上記のDBMakerインスタンスには、 .closeOnJvmShutdown .compressionEnable 等の各種のオプションが設定できる。詳細は http://www.mapdb.org/apidocs/org/mapdb/DBMaker.html#method_summary あたりを参照だが、重要なもののみちょっと解説しておく

    • .closeOnJvmShutdown : JVMが終了する際に.closeを呼ぶようにする。これの実装は.addShutdownHookなので、終了方法によっては実行されないケースがあるが、そういう場合は他の方法でもどうしようもないと思う。
      • 何回か試した限りでは、このcloseが実行されずに終了した場合でも、データベースファイルが破損するという事はないようだった(某dbmはすぐ破損する事で有名だった…)。
    • .transactionDisable : これは基本的には設定すべきではない。これを設定するとパフォーマンスが向上する代償として、「二つのテーブルに同時に変更を行い、コミットする」ような操作の途中で、何らかの要因によって処理が中断した際に「片方のテーブルにだけ変更が反映されてしまっている」状況が起こりえてしまうようになる。これを設定しなければ、とりあえずそのような状況は起こらない(コミット前かコミット後かのどちらかになる事を保証する)、との事。
      • 上記コード例では set! update! 内に .commit を埋め込んでしまっているので意味がないが、サンプルコードという事で勘弁してください
  • (.make db-maker-instance) で、上記の各種の設定を行ったDBMakerインスタンスから、DBインスタンスを生成する。このDBインスタンスがいわゆるRDBMSでの「データベース」に相当する。

  • (.getHashMap db-instance "table-name") で、java.util.Mapのインターフェースを持つ、いわゆるRDBMSでの「テーブル」に相当するインスタンスを生成できる。

注意点

  • いわゆる「真のトランザクション」をmapdbは提供していないので、自前で適切に並行処理を隔離する必要がある。

    • 「真のトランザクション」とは、STMのT部分。要は「二つのテーブルの内容を読んで、計算して、一つのテーブルを更新する」ような処理が複数同時に走っても大丈夫になるようにする的な奴。
    • 今回は安直にlockingでの実装とした。上記コードの最後のサンプルを参照
  • 定期的に .compact を実行する必要がある。

    • 昔のpostgresのvacuumが、データ量によってはものすごい時間がかかってメンテが終わらないという悪夢を起こしていた事で悪名高いが、これもそうなのかは不明…。しかしもしそうだったとしても実行しない訳には…。
    • データ量が少ない内は、core.asyncとかで適当に一時間に一回実行するとかだけでいいと思う