Python技术迷

一个纯Python编写的轻量级数据库 -- TinyDB

昨天晚上十一点多,我在公司楼下便利店门口蹲着喝豆奶,手机还夹着工牌,隔壁桌两个测试在吐槽: “就写个小脚本还要让我起 MySQL,我电脑风扇都要起飞了。” 我脑子里当时就飘过一句话——你咋不整一个 TinyDB 呢。

说实话,很多人第一次听 TinyDB 这个名字,反应跟听到什么新框架差不多:又一个数据库?我还得学语法?还得装服务? 其实完全不是那回事,它更像是:一个用纯 Python 写的小记事本,但长得像数据库,支持增删改查,还能写点条件查询,最关键,不用装服务进程,pip install 一下就能用。

所以我就干脆把昨天帮小伙子改脚本的经历,顺着说一下,顺便把 TinyDB 这个东西扒拉清楚。

我给他的第一句原话是: “你这玩意就一个人用,用完就走,没有高并发、没有事务隔离,你上啥 MySQL 啊,直接用个文件不香么?”

他马上回我一句: “用文件我还得自己管序列化、查找、更新,好麻烦。”

这就是 TinyDB 出场的地方了:帮你把“文件+JSON+各种查询”这一坨麻烦事打包好了,你只管拿着当小数据库用。

装起来就一行,没什么神秘的:

pip install tinydb

然后我们在他那个小脚本里,随手塞了几行:

from tinydb import TinyDB, Query

# 创建一个 JSON 文件作为“数据库”
db = TinyDB('data.json')

# 插入一条记录
user_id = db.insert({'name': 'Alice', 'age': 25, 'city': 'Shanghai'})
print('new user id:', user_id)

这会儿他就懵了:“就这?这就入库了?” 我让他去项目目录看,data.json 已经躺在那儿了,里面就是一堆 JSON。

这个体验跟传统数据库完全不一样:你不用建库建表,不用连远程,也不用起 docker,反正就是一个小文件,读写都 TinyDB 帮你包了。

他当时关心的第二个问题是:“那这个东西到底算啥?是 SQL 还是 NoSQL?”

我说你可以简单理解成: 它是一个文档型数据库,每条记录就是一个 Python dict,底层就写进 JSON 文件里,没有表结构限制,也没有 SQL 语句,全靠字段说话。

比如我们连续插几条风格完全不一样的数据,也是没问题的:

db.insert({'name': 'Bob', 'hobbies': ['basketball', 'music']})
db.insert({'device': 'iPhone', 'os': 'iOS', 'version': 18})
db.insert({'task': 'backup', 'status': 'running', 'progress': 42})

你看,这三条放在传统关系型数据库里,字段都不一样,表结构怎么设计都要纠结半天。 但在 TinyDB 这儿,它就当你是在往一个“大 JSON 列表”里 append 元素,一切随缘。

内部 TinyDB 会给每条记录分配一个 doc_id,有点像主键,但你平时不太需要手动管,插入的时候返回值就是这个 ID:

doc_id = db.insert({'name': 'Charlie'})
print(doc_id)   # 比如打印 4

print(db.get(doc_id=doc_id))
# -> {'name': 'Charlie'}

这个特性在你需要精确修改某一条的时候还挺好用,对吧。

他那会儿真正卡住的是查询。 原来用 MySQL,他写的是这样:

SELECT * FROMuserWHERE age >= 18AND city = 'Shanghai';

换成 TinyDB,不用 SQL,而是用一个 Query 对象来描述条件:

from tinydb import Query

User = Query()
adults_in_shanghai = db.search(
    (User.age >= 18) & (User.city == 'Shanghai')
)
print(adults_in_shanghai)

第一次看到这种写法的人,心里多多少少会嘀咕: “这也太像 ORM 了吧。”

实际上 TinyDB 做得还挺直观:

  • Query() 相当于一个“字段访问器”,User.age 就是字段
  • 比较运算符直接用 ==, >= 这些
  • &、| 做组合条件 最终那一坨表达式就是一个可执行条件,传给 search,TinyDB 会在内存里把 JSON 一条条扫一遍,符合就返回。

要是你嫌 Query() 这套写法太面向对象,还可以走“更糙一点”的方式:where:

from tinydb import where

shanghai_users = db.search(where('city') == 'Shanghai')

我们当时现场改小伙子脚本的时候,基本就是把他原来的 SQL 条件翻译成 Query 的写法,一两分钟就搞定了。

聊着聊着,他问一句:“那更新呢?我总不能删了再插吧?”

TinyDB 的更新和删除也是围绕查询来的,这点跟你在 ORM 里操作有点像。

比如把所有上海用户的年龄 +1:

User = Query()

db.update(
    {'age': User.age + 1},      # 这里可以用表达式
    User.city == 'Shanghai'
)

如果只想更新某个 doc_id:

db.update({'city': 'Beijing'}, doc_ids=[user_id])

删除也一样,用条件就行:

# 删除所有未激活用户
db.remove(User.active == False)

# 或者按 doc_id 删除
db.remove(doc_ids=[user_id])

我们当时用得比较多的是“逻辑删除”,就简单加个字段:

db.update({'deleted': True}, User.id == some_id)

因为 TinyDB 本质上只是在改 JSON 里的那条记录,本身没啥约束,怎么玩都靠你自觉。

写着写着,小伙子忽然说:“我这脚本不止要存用户,还要记一点操作日志,难道要搞两个 JSON 文件?”

其实不用,TinyDB 里面也有“表”的概念,不过不叫 table,而叫“表对象”,你可以理解成一个 JSON 里的不同“分区”。

# 默认表,名字叫 '_default'
default_table = db.table('_default')

# 单独的 users 表
users = db.table('users')
logs = db.table('logs')

users.insert({'name': 'Alice'})
logs.insert({'action': 'login', 'ts': '2024-01-01 10:00:00'})

data.json 打开大概是这种结构(示意一下):

{
"_default": [],
"users": [
    {"name": "Alice"}
  ],
"logs": [
    {"action": "login", "ts": "2024-01-01 10:00:00"}
  ]
}

这样你在代码里就非常清晰了:users.search(...) 只查用户,logs.search(...) 只查日志,互不影响。

我们后来把他脚本整理一下,直接封了一个“小仓库类”出来:

from tinydb import TinyDB, Query

classUserRepo:
def__init__(self, db_path='data.json'):
        self.db = TinyDB(db_path)
        self.table = self.db.table('users')
        self.User = Query()

defadd_user(self, name, age, city):
return self.table.insert({
'name': name,
'age': age,
'city': city
        })

deffind_by_city(self, city):
return self.table.search(self.User.city == city)

defmark_deleted(self, user_id):
        self.table.update({'deleted': True}, doc_ids=[user_id])

这样一来,后面脚本里面就全是:

repo = UserRepo()
repo.add_user('Alice', 25, 'Shanghai')
repo.find_by_city('Shanghai')

看着比到处 TinyDB('data.json') 清爽不少。

说到这儿,你可能会有点担心: “这不就是一个 JSON 文件吗?我每次 insert/update 不会频繁写磁盘吧?会不会把 SSD 写坏?”

TinyDB 默认的行为是:每次操作都会把数据写回文件,简单粗暴,所以写多了肯定有性能问题。 官方给的思路是加一层中间件,把写操作缓存起来,攒一波一起写。

比如加上缓存中间件:

from tinydb import TinyDB
from tinydb.storages import JSONStorage
from tinydb.middlewares import CachingMiddleware

db = TinyDB(
'data.json',
    storage=CachingMiddleware(JSONStorage)
)

# 正常增删改查...

# 进程结束前手动 flush 一下,确保写回磁盘
db.storage.flush()
db.close()

CachingMiddleware 的默认策略大致就是: 内存里先记着,积累到一定数量再刷到文件。你也可以查文档调参数,这里就不展开了。

还有个更常见的用法,是在单元测试里用内存版存储,完全不落地文件:

from tinydb import TinyDB
from tinydb.storages import MemoryStorage

db = TinyDB(storage=MemoryStorage)

db.insert({'test': True})
print(db.all())   # 进程结束数据就没了

这一套对写测试脚本特别舒服:不用起任何外部依赖,引入 TinyDB,就有一个“假数据库”了。

聊完了怎么用,免不了要聊一句:TinyDB 适合干嘛,不适合干嘛。 不然你哪天拿它去顶一个线上业务,那不就闹笑话了。

我一般会这么分:

适合的场景,大概是这些:

  • 小工具、小脚本:比如你写个 CLI 管理个人待办、记录某个统计数据
  • 桌面程序、本地小应用:不用让用户装数据库,带个 data.json 就行
  • 原型验证、demo:产品一拍脑袋要看个 demo,你懒得搭环境
  • 单元测试、教学代码:需要“像数据库一样”的东西,但又不想连真库

不太适合的场景也很明显:

  • 多人并发访问:TinyDB 没有复杂的锁机制,本质就是在改 JSON 文件,多进程乱写很容易把文件搞坏
  • 特别大的数据量:几百 MB 一下子读进内存,你自己都心虚,对吧
  • 事务、一致性、复杂查询:这些还是 SQLite/MySQL/Postgres 的活

我经常会帮人做一个对比: 同样都是“轻量级”,TinyDB 和 SQLite 的分界线在哪儿?

简单粗暴一点讲:

  • TinyDB:

    • 完全 Python 世界,pip install 就能跑
    • 文档型数据,字段随意
    • 小而简,适合脚本和玩具项目
  • SQLite:

    • 还是 SQL,还是表结构
    • 功能更完整,有事务、有索引
    • 数据稍微大一点、多人访问一点,用它更稳

我们昨天给小伙子定了个规矩:单机脚本、工具类项目 → 先用 TinyDB; 一旦要部署到服务器、要给别人用、要多进程访问 → 起码上 SQLite。

还有个细节,可能你一开始不会想到:数据迁移。

比如你早期用 TinyDB 存了一些配置、用户信息,后面项目长胖了,准备换正式数据库了,要不要从头重新搞?

其实 TinyDB 这点还蛮友好的: 数据本来就是 JSON,读出来导入别的数据库就行了。

我当时顺手给小伙子写了一个非常糙的迁移脚本,从 TinyDB 导出到 SQLite,给你参考一下思路:

import sqlite3
from tinydb import TinyDB

# 1. 打开 TinyDB
db = TinyDB('data.json')
users = db.table('users')

# 2. 准备 SQLite
conn = sqlite3.connect('users.db')
cur = conn.cursor()

cur.execute('''
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT,
    age INTEGER,
    city TEXT
)
'''
)

# 3. 把 TinyDB 里的数据一条条插过去
for doc in users.all():
    cur.execute(
'INSERT INTO users (name, age, city) VALUES (?, ?, ?)',
        (doc.get('name'), doc.get('age'), doc.get('city'))
    )

conn.commit()
conn.close()

你看,TinyDB 本身没有帮你做迁移工具,但因为底层是 JSON,你想咋弄就咋弄,这就比某些私有格式舒服多了。

写到这里,我手机突然响了一下,是那个小伙子给我发微信: “东哥,我这脚本打包给运营同事了,他们问 data.json 能不能改个名字,感觉太 ‘技术’ 了。”

我回他一句:“随便叫啥都行,你叫 xiaogongju.db 也没人管你。” 毕竟对 TinyDB 来说,文件名只是个路径而已:

db = TinyDB('xiaogongju.db')

它也不会真的去检查你是不是 .json 结尾。

行了,反正 TinyDB 这个小家伙就这样,你把它当“有增删改查能力的 JSON 文件”来用就行,别太当数据库神兽看,也别小看它。

我这会儿得去泡杯咖啡,刚才产品又拉我进了一个新群,说要做一个“本地离线小工具”…