jsondb-rb
把一个 JSON 文件当数据库读写的 Ruby 库。零运行时依赖(纯标准库),MongoDB-lite 查询语法,落盘永远原子。
gem 名
jsondb-rb(rubygems 的jsondb已被 2014 年旧库占用);require 路径与模块名不变:require 'jsondb'→JsonDb。
require 'jsondb'
db = JsonDb.open('app.json')
users = db[:users]
users.insert(name: 'Ann', age: 31, tags: %w[ruby db])
users.insert_many([{ name: 'Bob', age: 17 }, { name: 'Cara', age: 45 }])
users.where(age: { gte: 18 }).order(:name).limit(10).to_a
users.where('profile.city' => 'SZ', age: (18..60)).count
users.where('$or' => [{ vip: true }, { 'score' => { '$gte' => 90 } }]).pluck(:name)
users.update_many({ age: { '$lt' => 18 } }, '$set' => { 'minor' => true })
users.upsert({ name: 'Ann' }, '$inc' => { 'login_count' => 1 })
db.transaction do
db[:orders].insert(user: 'Ann', total: 99)
db[:users].update_one({ name: 'Ann' }, '$push' => { 'orders' => 'o-1' })
end # 一次落盘;块内抛错自动回滚
db.close数据文件长什么样,人可以直接编辑(改完下次读取自动重载):
{
"users": [
{ "name": "Ann", "age": 31, "_id": "5f3a…" }
]
}设计
- 单文件:顶层对象,键 = 集合名,值 = 文档数组。备份就是复制文件。
-
内存权威:读写都走内存;写操作后默认即时落盘(
autoflush: true)。 -
原子落盘:同目录临时文件 →
fsync→rename→ 目录fsync。任何时刻磁盘上只有完整的旧文件或新文件,绝无半截 JSON。 -
_id:文档唯一键;插入时缺省自动生成 24 位 hex。可自定义(insert(_id: 'u1', …)),不可变更。 -
键与值的字符串化:symbol 键/值入库时深度转为字符串(
name: :active↔"active"),保证 JSON 往返一致;查询过滤器里的 symbol 自动同样处理。
查询
链式:where(...).where(...)(AND 叠加)order / limit / offset / pluck / first / count / exists?,惰性执行,枚举时取快照。
字段名支持点路径,含数组下标:'profile.city'、'tags.0'。
操作符支持两种写法:字符串 '$gt' 与裸符号 gt:(age: { gte: 18 })。显式 $ 前缀写错操作符名会抛错;裸符号不在操作符表内则按数据 Hash 等值匹配。
| 过滤写法 | 含义 |
|---|---|
age: 31 |
等值 |
age: { '$gt' => 18 }($gte $lt $lte 同理) |
比较;缺失/类型不合不匹配、不抛错 |
age: (18..60) |
区间(Range) |
name: { '$ne' => 'Ann' } |
不等(缺失字段也算匹配) |
age: { '$in' => [18, 31] } / '$nin'
|
枚举(元素可为 Regexp) |
active: { '$exists' => true } |
键存在性(值为 false 也算存在) |
name: { '$regex' => /^A/ } |
正则(String 或 Regexp) |
tags: { '$size' => 2 } / '$all' => [...]
|
数组长度 / 全包含 |
age: { '$type' => 'integer' } |
JSON 类型(null/boolean/integer/float/string/array/object) |
age: { '$not' => { '$gte' => 18 } } |
字段级取反 |
'$and' / '$or' / '$nor' |
逻辑组合(空 $or 永不匹配) |
name: /^A/(直接 Regexp 值) |
等价 $regex
|
排序对混合类型安全(false < true < 数值 < 字符串 < 数组 < 对象 < nil,nil/缺失垫底;平键保持插入序)。order(:name)、order(:age, :desc)、order(age: :desc, name: :asc) 均可,多次调用逐键追加。
更新
| 写法 | 含义 |
|---|---|
update_one(f, { age: 32 }) |
裸 Hash = 按字段赋值(值整体替换) |
'$set' => { 'a.b' => v } |
点路径赋值,中间缺失自动补 {}
|
'$unset' => { 'a.b' => '' }(或数组) |
删键 |
'$inc' => { 'n' => 1 } |
数值增减(缺失视 0;目标非数值抛错,整批不落地) |
'$push' => { 'tags' => v } / { '$each' => [...] }
|
追加数组元素 |
'$pull' => { 'tags' => v } |
删等值元素;Hash 值按子过滤器删 |
update_one / update_many(批量)、update(id, changes)(按 _id)、upsert(filter, changes)(无则插:过滤条件 ⊕ 更新集合成文档)。操作符中途失败时该次调用全部不落地(先在副本上应用成功再写入)。
删除:delete(id) / delete_one(filter) / delete_many(filter) / clear!;db.drop(:name) 连集合一起删。
持久化与事务
-
JsonDb.open(path, pretty: true, autoflush: true);pretty: false出紧凑 JSON。 - 批量写入:
JsonDb.open(path, autoflush: false)后手动db.flush;或用db.transaction { … }(块内禁即时落盘,正常结束一次落盘,抛错整体回滚到事务前)。 -
db.close落盘并封存实例;db.reload!丢弃内存改动强制重读。 - 外部改动:无未落盘改动时,下次读取自动察觉(mtime+size)并重载——手编文件、多进程读都新鲜。本地有未落盘改动且文件又被他方改写时,打印一次警告,落盘以本地为准。
并发语义(如实声明)
- 单进程多线程:安全(Monitor 串行化,含
fork前后语义)。 - 跨进程:写经数据文件旁的
<path>.lockflock 串行化,落盘原子;多进程并发写为文件级 last-write-wins,不合并——两进程同时各自写不同文档,后落盘者覆盖整文件。写者之间靠 mtime 重载尽量累积对方改动,但读-改-写竞态窗口内的丢失不设防。需要强一致多写,请单写者或外部协调(这也是同类库 lowdb/TinyDB 的共同边界)。 - 读永远一致:任何进程任何时刻读文件,只会看到完整的某一版。
约束与边界
- 文档只支持 JSON 原生类型(Hash/Array/String/Numeric/Boolean/nil);Date/Time 等请自行转字符串。symbol 自动转字符串。
- 无索引:每次查询全集合扫描。万级文档以下舒适,十万级请上真数据库。
-
$elemMatch、投影(projection)、聚合:暂无,见需再加。
性能
复现:rake bench(微基准 ips)/ rake bench[scale](规模表)。脚本在 benchmark/,数据为本机实测(Ruby 4.0.6, arm64 M 系列 MacBook,2026-10)。
缩放表(预填后 autoflush 关闭测内存操作,flush 单列;查询无索引全扫描):
| 操作 | 1 000 文档 | 5 000 文档 | 20 000 文档 |
|---|---|---|---|
| reload(整库读+解析) | 0.70 ms | 6.9 ms | 17.0 ms |
| 等值查询(命中 1%) | 3.1 ms | 9.1 ms | 50.8 ms |
| 范围查询 | 2.4 ms | 13.0 ms | 61.9 ms |
| 排序 + limit 10 | 10.8 ms | 66.8 ms | 352.8 ms |
| count(带过滤) | 1.9 ms | 7.8 ms | 33.8 ms |
| insert ×100(内存) | 6.3 ms | 28.7 ms | 158.0 ms |
| update ×100($inc,未命中全扫) | 174 ms | 826 ms | 3 388 ms |
| flush(全量原子落盘) | 1.7 ms | 4.9 ms | 13.9 ms |
| 数据文件大小 | 235 KB | 1.2 MB | 4.8 MB |
单操作亮点(1 000 文档,微基准):
| 操作 | 吞吐 | 单次 |
|---|---|---|
| update by _id(命中首条即止) | 44 000/s | 23 µs |
| docs 快照 | 295 万/s | 339 ns |
| insert(内存,小集合) | ~2 700/s | 371 µs |
| insert(原子落盘) | ~510/s | 1.95 ms |
| update(原子落盘) | ~460/s | 2.18 ms |
| reload 1k 文档 | 1 460/s | 684 µs |
读数要点(诚实边界):
- 扫描吞吐约 50–60 万文档/秒(无索引线性扫,与集合规模基本无关的单文档成本 ~2–3 µs)
-
每次落盘 ≈ 1.9–2.2 ms(tempfile+fsync+rename+目录 fsync 的真实代价);高频写建议
autoflush: false批量flush,或transaction合并 - update/delete 按过滤条件为全扫(未命中尤甚,上表 update 行即最坏情形);按
_id命中快 - 排序为比较器全排(20k 文档 ~350 ms),键已预计算(装饰-排序-去装饰);跨类型安全比较是刻意设计,换取任意 schema 不抛错
- 万级文档舒适,十万级且高频查询请上真索引型数据库
安装
# Gemfile(gem 名 jsondb-rb,require 路径 jsondb)
gem 'jsondb-rb', '~> 0.1'gem install jsondb-rb # 然后 require 'jsondb'发版走 git tag(v*)触发 release.yml,经 rubygems OIDC trusted publishing 推送。
测试
rake test # 44 例:CRUD / 查询操作符 / 持久化 / 事务 / 多线程 / 多进程 fork 并发Ruby >= 3.1,纯标准库。