序
私は最近割とデータベース難民をしている。
まあ、それ以前はしていなかったのかと言われると怪しいけれど、昔はBerkeley DBを主に使っていた。
オブジェクトシリアライズがあまり一般的でない時代はdbm系のKVSを使っていることが多かった。あるいは、SQLite。 RubyでMarshalが使えるようになると、PStoreを使うようになった。
dbm系のデータベースは万能解ではないし、それよりは階層化可能で含まれる情報も多いファイルシステムベースのデータベースのほうが利便性は高い。ただ、複数エンティティにまたがるような状況ではファイルシステムベースは性能面で厳しいことが多い。
その話は置いとくとして、dbm系データベースを使うことが良い結果になるものというのはそれなりにあったのだが、ライセンス的問題、性能的問題、機能的問題などでより優れた実装を必要とした。 QDBM, Tokyo Cabinet, Kyoto Cabinet, Tkrzwなど。
今なら有力なのはLMDBだろう。dbm型のKVSにおいてLMDBは強力であり、およそ満足できるものだというのは、一旦話の前提として受け入れてほしい。
まぁ実際は、トランザクションを張るコストが非常に重い、という問題があったりするのだが……
LMDBの問題は(dbm実装全体に言えることだが)その操作に言語バインディングの有無がものすごく影響するも、ということだ。 ライブラリなのでそれはそうなのだが、バインディングのない言語で実現するために、バインディングのある言語で実装したサーバーを用意して代理させる、みたいなことはありがち。
このため、LMDBは「安牌」や「鉄板」として使用できるようなものではなく、LMDBのバインディングのない言語でデータベースが必要なときに割と困ってしまう。
ここは「選択しやすいデータベース」、つまりは
- 機能的に満足できる
- プロセス・スレッド両面で競合に対する対処を自分で書かなくていい
- IDのわかっている単一のエンティティを一発で引ける
- 値を以上、以下、範囲で探せる (対象はキーだけでもいい)
- エンティティに順序の概念がある (ページネーションが可能)
- データベース側のアップデート等でマイグレーションしたり掃除したりが発生しない
- データベースの停止を伴うメンテナンスがいらない
- ファイルベースで簡単にバックアップできる
- セットアップに大きな手間を要求しない
- ホストが変わった時に設定変更を要求されない
- 任意に停止できる、もしくはそもそもサーバーではない
- アプリケーションごとに(ファイルパスなどで)そもそも固有であるか、複数アプリケーションが共存できる
- バインディングの有無に依存しない
- (オンメモリなどで)サーバーリソースを過剰に要求しない
- SQLを使わず、構造化された方法で操作できる
あたりを満たすものを探し、比較的良さそうだなと思ったのがCouchDBだ。
ちなみに、他の気になる候補としてはEJDB2, UnQliteがある。
事前知識
まず、CouchDBの特徴についていくつか覚えておいたほうが良いことがある。 これが分かっていないと、存在しないものを探して無駄な時間を過ごすことになる。
トランザクションはない
複数のドキュメントを排他的かつアトミックに更新する方法はない。
単一のドキュメントの更新は整合性をもって行われる。
更新は早い者勝ち
CouchDBのドキュメント更新は先勝ち。
ドキュメントにはリビジョンがあり、更新する場合更新しようとしているリビジョンを指定する。
同じリビジョンを指定しての更新は1度しかできず、2回目以降は409 Conflictが返ってくる。
{"error":"conflict","reason":"Document update conflict."}また、同じ理由で既存のエンティティを上書きすることもできない。
作成と更新のリクエストの違いはリビジョン指定の有無だけで、指定しなければ作成。
そして、既にエンティティがあるなら作成は409になる。
検索にはインデックスがあったほうが良い
検索項目に対するインデックスの作成は任意だけど、あったほうが速くて効率的。
CouchDBを使ってみる
準備
Archlinuxならextraにある。
pacman -S couchdb先に/etc/couchdb/local.iniでadminパスワードを入れておく必要がある。
[admins]
admin = thisispasswdここは平文で良くて、この状態で起動すると勝手にハッシュ化される。
systemctl start couchdb.serviceデフォルトで127.0.0.1:5984をlistenしている。
curl -v 127.0.0.1:5984
* Trying 127.0.0.1:5984...
* Established connection to localhost (127.0.0.1 port 5984) from 127.0.0.1 port 51682
* using HTTP/1.x
> GET / HTTP/1.1
> Host: localhost:5984
> User-Agent: curl/8.18.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Cache-Control: must-revalidate
< Content-Length: 257
< Content-Type: application/json
< Date: Wed, 28 Jan 2026 14:46:28 GMT
< Server: CouchDB/3.5.1 (Erlang OTP/28)
< X-Couch-Request-ID: 5cd7e7fdfa
< X-CouchDB-Body-Time: 0
<
{"couchdb":"Welcome","version":"3.5.1","git_sha":"44f6a43d8","uuid":"a3e461f3ed10c2e80384b7953bae5a55","features":["quickjs","access-ready","partitioned","pluggable-storage-engines","reshard","scheduler"],"vendor":{"name":"The Apache Software Foundation"}}
* Connection #0 to host localhost:5984 left intact
まずは基本のシステムコレクションを作る。
curl -v -X PUT -u admin:thisispasswd http://127.0.0.1:5984/_users
curl -v -X PUT -u admin:thisispasswd http://127.0.0.1:5984/_replicator
curl -v -X PUT -u admin:thisispasswd http://127.0.0.1:5984/_global_changes存在しないデータベースにPUTするとデータベースが作られる。
curl -X PUT -u admin:thisispasswd http://127.0.0.1:5984/test1GUI (Fauxton)
管理用のGUIがある。
デフォルトでURLはhttp://127.0.0.1:5984/_utils。
_始まりは内部的なもの、と非常に明瞭なルール。
ユーザーを作る
CouchDBにおけるユーザーの考え方はだいたい3つ
- 全部Admin。ユーザーなどいらぬ
- Couch per user. 1ユーザー1データベース方式
- 普通にユーザー作って権限割り当て
peruserする場合のユーザーは、1アプリケーション1ユーザーではなく、1アプリケーションユーザー1ユーザーな感じ。 この場合、CouchDBを複数のプログラムが共有している状態は考えにくい。
ユーザーを作るには_users/org.couchdb.user:<ユーザー名>にドキュメントを作る。
curl -v -X PUT -u admin:thisispasswd -H "Content-Type: application/json" -d @data.json http://127.0.0.1:5984/_users/org.couchdb.user:testuser{
"name": "testuser",
"password": "testpasswd",
"roles": [],
"type": "user"
}ユーザーも普通にドキュメントという方式。
更新は_revを指定してのドキュメントのアップデート。
ユーザーの割り当ては各データベースの_security
curl -v -u admin:thisispasswd -X PUT -H "Content-Type: application/json" -d @data.json http://127.0.0.1:5984/test1/_security{
"admins": {
"names": ["admin"],
"roles": []
},
"members": {
"names": ["testuser"],
"roles": []
}
}これでユーザーでアクセスできるようになる
_securityは常に上書きされ、_revでの更新は不要。
複数ユーザーで使う可能性があるならロールベースにするのが楽。
こんな感じでユーザーのrolesに文字列を入れて
curl -v -X PUT -u admin:thisispasswd -H "Content-Type: application/json" -d @data.json http://127.0.0.1:5984/_users/org.couchdb.user:testuser{
"name": "testuser",
"password": "testpasswd",
"roles": ["testseries"],
"type": "user"
}_security側でも受け入れる。
curl -v -u admin:thisispasswd -X PUT -H "Content-Type: application/json" -d @data.json http://127.0.0.1:5984/test1/_security{
"admins": {
"names": ["admin"],
"roles": []
},
"members": {
"names": [],
"roles": ["testseries"]
}
}利用
作成
キー指定で入れる
curl -X PUT -u testuser:testpasswd -H "Content-Type: application/json" -d '{"name": "haruka"}' http://127.0.0.1:5984/test1/AABB{"ok":true,"id":"AABB","rev":"1-17d89e98c187bc99458b79bbc6364d3a"}
更新
作成時やGET時にrevが返ってくる。
これを指定して更新する。
curl -X PUT -u testuser:testpasswd -H "Content-Type: application/json" -d '{"name": "haruko", "_rev": "1-17d89e98c187bc99458b79bbc6364d3a"}' http://127.0.0.1:5984/test1/AABBこの更新が成功するのは、前述した通りこの_revが最新であるときだけ。
そうでないなら409 Conflictになる。
取得
これも単純
curl -X GET -u testuser:testpasswd http://127.0.0.1:5984/test1/AABB削除
削除はrevをURLパスパラメータで渡す。
curl -X DELETE -u testuser:testpasswd http://127.0.0.1:5984/test1/AABB"?rev=2-5a09c434fdf8e4c5edb8f61c4ab5e46d"「削除された新しいドキュメントができる」という挙動。
% curl -X GET -u testuser:testpasswd http://127.0.0.1:5984/test1/AABB
{"error":"not_found","reason":"deleted"}
この状態で同じキーに対して作成をかけることは可能。 その場合も、さらに次のリビジョンとして作られる。
検索
_findが便利。
curl -X POST -u testuser:testpasswd -H "Content-Type: application/json" -d '{"selector": {"name": "haruka"}, "limit": 20}' "http://127.0.0.1:5984/test1/_find"{"docs":[
{"_id":"00AA","_rev":"2-56cc9189f04d121a850dce353ef541e2","name":"haruka"},
{"_id":"00AB","_rev":"1-17d89e98c187bc99458b79bbc6364d3a","name":"haruka"},
{"_id":"ABAB","_rev":"1-17d89e98c187bc99458b79bbc6364d3a","name":"haruka"},
{"_id":"ABAC","_rev":"1-17d89e98c187bc99458b79bbc6364d3a","name":"haruka"}
],
"bookmark": "g1AAAABAeJzLYWBgYMpgSmHgKy5JLCrJTq2MT8lPzkzJBYqzODo5OoPkOGByOUBRRpAUW0ZiUWl2YlYWABtfERk"}
このbookmarkの値を使って続きを検索できるのでページングが可能。
curl -X POST -u testuser:testpasswd -H "Content-Type: application/json" -d '{"selector": {"name": "haruka"}, "limit": 20, "bookmark": "g1AAAABAeJzLYWBgYMpgSmHgKy5JLCrJTq2MT8lPzkzJBYqzODo5OoPkOGByOUBRRpAUW0ZiUWl2YlYWABtfERk"}' "http://127.0.0.1:5984/test1/_find"インデックス作成
結構複雑というか、がんばりがいる部分。私はまだよく分かっていない。
とりあえず機能するようにするなら
curl -X POST -u admin:thisispasswd -H "Content-Type: application/json" -d '{"index": {"fields": ["name"]}, "name": "name-index", "type": "json"}' "http://127.0.0.1:5984/test1/_index"一括作成
_bluk_docsで"docs"プロパティに配列を入れる形でやる。
便利なようであまり使うことがない。 前述の通り、一括で作ってもアトミックではないからだ。
% curl -X POST -u testuser:testpasswd -H "Content-Type: application/json" -d '{"docs":[{"_id": "ACAA", "name": "haruka"}, {"_id": "ACAB", "name": "haruka"}]}' http://127.0.0.1:5984/test1/_bulk_docs
[{"ok":true,"id":"ACAA","rev":"1-17d89e98c187bc99458b79bbc6364d3a"},{"ok":true,"id":"ACAB","rev":"1-17d89e98c187bc99458b79bbc6364d3a"}]
データベース管理
CouchDBのデータは単なるファイルなので、「停止してファイルをバックアップ」で安全にバックアップできる。
場所はdatabase_dirとview_index_dirで書いてある。
サーバーマイグレーション時、_securityは含まれていないので、そっちは手動で複製。
PouchDB
PouchDBは基本的にJavaScriptのライブラリであり、CouchDBに対してsyncすることができる、というのが良さ。
CouchDBの「代わりに」使うのは……ちょっと厳しい。 そういう使い方をするためのものではないし、Node.jsから使うこと自体はできるけど、1つのPouchDBを複数Node.jsからひとつのPouchDBにアクセスするのはちょっと危険らしい。
Node.jsで使う場合はCouchDBの下流データベースとして使い、ブラウザで使う場合はクライアント完結で使うか、CouchDBに対する「オフラインDB」として使うか、が良いみたい。
感想
トランザクションないから万人向けじゃないよね。
トランザクションがあったほうが明らかに人間フレンドリーで、トランザクションがないってなるとデータベース設計のセンスを問われることになるから、ハードルだいぶ上がる。
ただ、昔はMap/Reduceで検索する方式だったけど、便利な_findができていて使いやすくなったとは思う。
言語等に中立で汎用性がある方法っていうとファイルシステムベースJSONが一番だと思うんだけど、Node.jsでは使いづらいのと、更新が必要な場合はロックが必要で、ファイルのロックって割と難しい要素が色々あるので完璧な解ではない。 ファイルシステムベースJSONが解決してくれるケースは、更新のない「一方的に作成するだけ」のパターン。 これで済むように設計できることは多いけど、その設計の難易度は高いし、できないこともある。
その意味で、更新もスムーズにできるのは魅力的だと思う。
CouchDBのアクセスに求められるのはHTTPクライアントとJSONパーサー/シリアライザー。 言語から使いやすいかどうかはその部分の出来で決まる。
ふふふ、動的型付け言語の勝利だ。
ちなみに、Rubyはどっちも標準であるけど、Net::HTTPの使い心地があまりよくないため微妙なほう。
サーバープロセスであり、名前空間分離ができるわけでもないから手軽さはそこまで高くはないかな。 Dockerでアプリケーション専用にすればまた話は違うけど、Dockerで動かすのを手軽とは言わない。 それはいろんなサーバープロセスを動かすことがない人の感覚。
更新は完全な先勝ちで、後勝ち挙動にしたい場合がちょっと困る。 409を返されて、クライアント側で再リクエストする必要がある。 更新の競合が滅多に発生しないならそれでもいいけど、頻繁に発生するなら設計を考え直すか、リトライせずに409を伝播するかといったことが必要で、このあたりを考えないといけないのはしんどいって人はいると思う。