【避坑指南】从 TinyDB 文件损坏,聊聊文件截断与磁盘刷盘的底层原理

Share
【避坑指南】从 TinyDB 文件损坏,聊聊文件截断与磁盘刷盘的底层原理
Photo by Shubham Dhage / Unsplash

在 Python 轻量级开发中,TinyDB 因其“零部署、文件即数据库、支持对象化查询”的特性,成为了存储配置信息、多租户元数据的神器。

但在高频写入或异常崩溃的场景下,你是否遇到过这样的诡异现象:导出的 JSON 文件末尾莫名其妙多出了几个 NULNUL\x00)空字节,导致整个数据库报 JSON 无法解析的错误?

本文将带你还原这个经典的“文件空洞”Bug,并分享如何通过自定义存储类 MyJSONStorage 彻底解决它。

一、 现象还原:消失的尾巴与诡异的 NUL

在默认情况下,TinyDB 的 JSONStorage 是这样写入文件的:

  1. seek(0) 指针回到文件开头。
  2. write(json_data) 写入序列化后的 JSON 字符串。
  3. truncate() 截断文件,切掉因为新数据变短而残留的旧数据。

看似完美的逻辑,在实际并发、异常中断、或者操作系统缓存延迟落盘时,会触发一个底层漏洞:文件空洞(File Hole)

write() 写入的数据还停留在操作系统的 Page Cache(内存缓存) 中,而没有真正刷新到物理磁盘时,如果直接调用 truncate(),或者指针由于并发发生错乱,操作系统为了填补“指针所在位置”与“实际落盘位置”之间的空白,就会自动填充 \x00(即 NUL 字符)。

最终的结果就是:你的 JSON 文件末尾多了一串不可见的乱码,下次读取时数据库直接崩溃。

二、 终结者:自定义 MyJSONStorage

为了彻底解决这个问题,我们需要重写 TinyDB 的存储引擎,引入强一致性硬刷盘机制。以下是核心实现代码:

Python

import io
import json
import os
from tinydb.storages import JSONStorage

class MyJSONStorage(JSONStorage):
    def write(self, data):
        try:
            # 1. 移动指针到开头
            self._handle.seek(0)

            # 2. 序列化 JSON 数据
            serialized = json.dumps(data, **self.kwargs)

            # 3. 写入数据
            try:
                self._handle.write(serialized)
            except io.UnsupportedOperation:
                raise IOError(
                    f'Cannot write to the database. Access mode is "{self._mode}"'
                )

            # ======= 核心改进 1:强制刷盘与同步 =======
            self._handle.flush()               # 刷出 Python 用户态缓冲区
            os.fsync(self._handle.fileno())    # 强行触发内核态物理刷盘(关键!)

            # ======= 核心改进 2:精准位置截断 =======
            self._handle.truncate()
        except Exception as e:
            self._handle.truncate()
            raise e
        finally:
            # ======= 核心改进 3:极端异常兜底 =======
            self._handle.truncate()
            
db = TinyDB( "db.json", storage=MyJSONStorage)

改进原理解析:

  1. self._handle.flush() + os.fsync()(核心大招): 普通的 write 只是把数据交给了操作系统,操作系统什么时候写到硬盘全看心情。os.fsync() 是一个重量级的系统调用,它会阻塞线程,强制让硬件磁头或 SSD 将数据瞬间物理落地
  2. 绝对精准的 truncate(): 正因为数据已经 100% 落地,此时的文件指针(Cursor)精准地停留在最新 JSON 字符串的最后一个字符后面。这时执行不带参数的 truncate(),可以完美地把后面残存的旧数据切掉,不多不少,绝不留空洞
  3. 三重 truncate() 异常兜底: 无论在写入时发生什么级别的崩溃(try 块报错、except 捕获、还是最后退出的 finally),统统强制执行一次 truncate(),彻底斩断 NUL 字符生成的可能性。

三、 为什么 TinyDB 官方不默认这么改?

你可能会问:“既然这个方法这么好,为什么 TinyDB 官方不把它内置进 JSONStorage 呢?”

这触及到了开源库的设计哲学(Trade-offs)

  • 性能代价太高os.fsync() 是一个非常昂贵的操作,它需要等待物理硬件响应。一旦默认开启,TinyDB 的连续写入性能可能会暴跌 10~100 倍
  • 定位不同:TinyDB 官方给它的定位就是“追求极致轻量、快速”。它刻意保持了几百行的精简源码,并将这种高级的数据安全性需求,留给开发者通过 “自定义存储(Custom Storage)” 独立扩展。

四、 最佳实践场景:什么时候该用它?

既然有性能损耗,我们就不应该盲目滥用。在实际项目中,我们推荐将数据进行动静分离 / 轻重分离

  • 不适合使用 TinyDB / MyJSONStorage 的场景: 高频写入的用户流水、日志系统、涉及多表联查的业务订单系统。这类场景请老老实实上 PostgreSQL / MySQL
  • 最适合使用 TinyDB + MyJSONStorage 的场景: 系统的全局配置多租户路由元数据(Tenant Info)、桌面应用的本地数据。这类数据总量极小、修改频率极低(往往几天才改一次),但对数据的完整性、准确性要求达到了 100%。用轻量级的 TinyDB 配合硬刷盘,既省去了部署大数据库的麻烦,又兜住了数据绝不损坏的底线。

总结

在底层的世界里,没有魔法。一行小小的 os.fsync(),虽然牺牲了一点点写入性能,却为我们的轻量级数据库加上了一道坚固的保险锁。如果你的 TinyDB 也正在饱受文件损坏的困扰,不妨试试把 JSONStorage 替换为 MyJSONStorage 吧!

Read more

搭建K3s集群

搭建K3s集群

K8s环境部署 简介 搭建 K3s + Rancher + Longhorn + MetalLB环境 K3s K3s是一款轻量级Kubernetes发行版,其核心优势在于: * 极致轻量:单个二进制文件不到100MB,仅需512MB内存即可运行,启动和资源消耗远低于标准集群。 * 功能齐全:通过CNCF认证,100%兼容标准Kubernetes API,并内置了常用组件,开箱即用。 * 灵活的数据存储:可以使用外部数据库(mariadb)替代etcd,降低初始资源。 Rancher Rancher是一个开源容器管理平台,核心能力包括: * 统一纳管:可集中管理任意K8s集群(包括K3s/RKE2、云厂商托管集群及已有集群)。 * 降低门槛:提供直观图形界面及开箱即用的CI/CD、监控、服务网格等工具链。 * 安全合规:支持AD/LDAP对接及精细化RBAC,实现跨集群统一安全策略。 Longhorn Longhorn 是一款专为 Kubernetes 设计的轻量级、可靠且易用的分布式块存储系统,它通过容器和微服务将现有存储资源转化为持久卷,

By 樊泽豪
Docker常用操作

Docker常用操作

安装 Windows安装Docker到F盘(非系统盘) Start-Process -FilePath 'Docker_Desktop_Installer.exe' -Wait -ArgumentList "install --installation-dir=F:\DockerDesktop" 改变容器、镜像文件位置 以管理员权限启动Docker Desktop,Settings-Resources-Disk iamge location 配置dockerhub国内源 阿里云:容器镜像服务 (aliyun.com) 其他源 more /etc/docker/daemon.json 输入以下文件: { "registry-mirrors": [ "https://kk8u6omk.mirror.aliyuncs.com", "https:

By 樊泽豪
如何在 Ghost 博客中添加类似 Word 的左侧固定目录(纯干货)

如何在 Ghost 博客中添加类似 Word 的左侧固定目录(纯干货)

当我们在 Ghost 博客中撰写长文时,一个类似 Word 导航窗格的目录能极大提升读者的阅读体验。本文将分享如何通过 Code Injection(代码注入) 快速实现一个完全静止、不随点击乱跳、长标题自动换行的优雅左侧悬浮目录。 实现效果 * 完全固定: 目录稳居文章左侧,只有正文跟随鼠标滚动。 * 绝对静止: 修复了常见插件点击目录项时,目录自身会产生二次跳动的痛点。 * 清晰完整: 长标题自动换行显示,绝不裁剪文字。 * 移动端友好: 在手机或平板等小屏幕上自动隐藏,防止遮挡正文。 部署步骤 无需修改任何主题源文件,只需登录 Ghost 后台,进入 Settings -> Code Injection(代码注入),将以下两段代码全选覆盖粘贴即可。 1. 注入到 【Site Header】 在 Site Header 框中复制并完全粘贴以下代码(包含 Tocbot 官方样式与自定义微调

By 樊泽豪
Git Worktree 完全指南:告别分支切换焦虑

Git Worktree 完全指南:告别分支切换焦虑

在多个分支间频繁切换,每次都要重新加载环境、重启服务?git worktree 让你同时拥有多个工作目录,互不干扰,效率翻倍。 为什么需要 Worktree? 日常开发中,我们经常面临这样的场景: * 正在 feature 分支开发新功能,突然要紧急修复 hotfix 分支的 Bug * 想同时对比两个分支的代码差异,或者并行跑两个版本的服务 * 每次切换分支,IDE 都要重新索引,编译缓存失效,等待时间漫长 常规的 git checkout 或 git switch 虽然能切换分支,但同一时间只能在一个分支上工作。如果你切走再切回来,之前的环境(如依赖安装、编译产物)可能已经丢失或需要重建。 git worktree 的解决方案:在同一个 Git 仓库中,创建多个独立的工作目录,每个目录对应不同的分支,它们共享同一个 .git 对象库(

By 樊泽豪