1 引言
TinyDB 的 JSONStorage 类的 write 方法中在数据刷盘以后调用 file.truncate() 方法,该方法的作用是什么呢?
实际上,该方法用于截断原始数据,否则可能导致数据写入异常。
这一步虽然很简单,但是很重要。比如在写入文件时如果不注意的话,有可能导致覆盖写入数据丢失。
本文将复现没有调用 file.truncate() 方法时的写入异常,并结合文件写入操作介绍相关原理,包括文件指针与文件写入模式。最后基于存储模型分别举例分析 MySQL 与 minidb 两种数据库对应删除操作的实现方式。
2 介绍
JSONStorage 类的 write 方法的实现如下所示。
class JSONStorage(Storage):
def write(self, data: Dict[str, Dict[str, Any]]):
# Move the cursor to the beginning of the file just in case
self._handle.seek(0)
# Serialize the database state using the user-provided arguments
serialized = json.dumps(data, **self.kwargs) # 数据序列化
# Write the serialized data to the file
self._handle.write(serialized)
# Ensure the file has been written
self._handle.flush() # 强制刷盘
os.fsync(self._handle.fileno())
# Remove data that is behind the new cursor in case the file has
# gotten shorter
self._handle.truncate() # 截断原始数据
write 方法中主要包括以下几个操作:
-
文件指针移动到文件头,file.seek(0) -
数据序列化,json.dumps -
数据写入,file.write -
数据强制刷盘,file.flush + os.fsync -
截断文件中的原始数据,file.truncate
注意最后一步调用 file.truncate() 方法截断原始数据,该方法的作用是什么呢?
查看项目的 issue,发现 remove with json database results in json file with extra data at the end #1 中反馈了写入后数据异常的案例,并提供了测试用例用于复现。
项目中最终通过调用 file.truncate() 方法修复该 bug。
下面首先参考测试用例复现报错,然后分析原因。
3 复现
3.1 准备
先注释掉 file.truncate() 方法。
# self._handle.truncate()
补充一句,MemoryStorage 不存在该问题,原因是数据没有持久化。
实例化 TinyDB 对象时,如果没有指定存储类型,则默认使用 JSONStorage。
class TinyDB(TableBase):
...
default_storage_class = JSONStorage
def __init__(self, *args, **kwargs) -> None:
storage = kwargs.pop('storage', self.default_storage_class)
self._storage = storage(*args, **kwargs) # type: Storage
TinyDB 数据库磁盘中的数据结构是 JSON 字符串,内存中的数据结构是字典。
3.2 操作
首先向空文件中插入记录 item1,然后删除该记录,再插入另一条记录 item2。
from tinydb import TinyDB, where
# empty file
db = TinyDB('test.json')
item1 = {'name': 'A very long entry'}
item2 = {'name': 'A short one'}
db.insert(item1)
# test.json contains:
# {"_default": {"1": {"name": "A very long entry"}}}
db.remove(where("name") == "A very long entry")
# test.json contains:
# {"_default": {}}": {"name": "A very long entry"}}}
db.insert(item2)
# test.json contains:
# {"_default": {"2": {"name": "A short one"}}}ry"}}}
其中在插入第二条记录时,报错 JSON 格式非法。
json.decoder.JSONDecodeError: Extra data: line 1 column 17 (char 16)
如下所示,对比三次操作后数据文件中的内容。
{"_default": {"1": {"name": "A very long entry"}}}
{"_default": {}}": {"name": "A very long entry"}}}
{"_default": {"2": {"name": "A short one"}}}ry"}}}
可见,在删除 item1 时,会写入空 json 到文件中,但是实际上之前的数据还没有完全删除,因此导致下次数据写入异常。
4 原理
4.1 删除
实际上,这里所谓的删除其实是从文件头重新写入,并不是真正的删除。
因此,如果新写入的数据长度更长,那么直接覆盖写要删除的空间,否则就可能出现数据写入异常的情况,新数据和部分老数据同时存在。
# Move the cursor to the beginning of the file just in case
self._handle.seek(0)
# Serialize the database state using the user-provided arguments
serialized = json.dumps(data, **self.kwargs) # 数据序列化
# Write the serialized data to the file
self._handle.write(serialized)
借助该 bug 也可以理解下文件指针的作用。
注意写入之前会先将文件指针移动到文件头,原因是每次都是从文件头开始写入。
self._handle.seek(0)
4.2 文件指针
文件指针用于标明文件读写的起始位置。
file.seek() 方法用于移动文件指针到文件的指定位置。
方法声明如下所示。
def seek(self, offset: int, whence: int = 0) -> int:
其中:
-
入参:
-
offset,开始的偏移量,也就是代表需要移动偏移的字节数;
-
whence,可选,默认值为 0。给 offset 参数一个定义,表示要从哪个位置开始偏移。支持以下三种枚举值,其中:0 代表从文件开头开始算起,1 代表从当前位置开始算起,2 代表从文件末尾算起。
-
出参:
-
如果操作成功,则返回新的文件位置,如果操作失败,则函数返回 -1。
有一个小技巧是借助 seek 方法判断指定文件是否为空。
TinyDB 中 JSONStorage 类的 read() 方法中首先调用 seek(0, 2) 方法将文件指针移动到文件尾,然后根据文件指针的位置判断是否是空文件。如果不是空文件,则将文件指针再次移动到文件头,然后加载数据。
def read(self) -> Optional[Dict[str, Dict[str, Any]]]:
# Get the file size by moving the cursor to the file end and reading
# its location
self._handle.seek(0, os.SEEK_END) # 文件尾
size = self._handle.tell() # 判断文件大小
if not size: # 空文件
# File is empty, so we return ``None`` so TinyDB can properly
# initialize the database
return None
else:
# Return the cursor to the beginning of the file
self._handle.seek(0) # 文件头
# Load the JSON contents of the file
return json.load(self._handle) # 加载数据
read() 方法中需要先判断是否为空文件,然后调用 json.load 加载数据的原因是如果文件为空,调用该方法时将同样报错 JSON 格式非法。
因此,比如 tables() 方法中会在调用 read() 方法返回 None 的条件下返回空字典。
def tables(self) -> Set[str]:
return set(self.storage.read() or {})
4.3 文件写入模式
类似的,文件操作过程中,如果在重复写入之前不移动文件指针的位置,有可能导致写入覆盖。这里,引入文件写入模式的概念。
如下所示,两次写入同一文件导致写入覆盖,本质上与上文 TinyDB 中的写入异常相同。
>>> f = open("aa.txt", "r+")
>>> f.write("abc") # 第一次写入
3
>>> f.close()
>>> f = open("aa.txt", "r+")
>>> f.write("d") # 第二次写入
1
>>> f.close()
>>> f = open("aa.txt", "r+")
>>> f.read() # 写入覆盖
'dbc'
>>> f.close()
要解决该问题,就需要介绍下文件写入模式。
Python 的 file.open 方法共支持以下几种文件写入模式,其中默认为r
。
写入模式 | 含义 |
---|---|
r | 读取 |
w | 清空文件内容后写入,如果文件不存在,新建文件后写入 |
x | 新建文件后写入,如果文件已存在,直接报错 |
a | 如果文件已存在,末尾写入,否则新建文件后写入 |
b | 二进制模式 |
t | 文本模式 |
+ | 读写 |
因此,需要根据场景选择文件写入模式,比如:
-
如果文件每次都是追加写入,历史数据保留且不变,可以使用 a
; -
如果文件每次都是重新写入,历史数据不保留,可以使用 w
; -
如果文件每次都是重新写入,历史数据保留且可变,可以使用 r
。
如果除了写入,还需要读取,写入模式后追加+
字符。
由于 TinyDB 作为数据库,历史数据保留且可变,并且每次是重新写入,支持读取,因此默认使用r+
。
那么,其他数据库中的删除操作会遇到类似的问题吗?
下面,我们分别以 MySQL 与 minidb 举例分析,其中 minidb 是一个实现 k-v 存储引擎的开源项目。
5 拓展
5.1 MySQL
5.1.1 物理存储与逻辑存储
MySQL 中物理文件对应逻辑上的库和表(database & table)。
逻辑上的库对应独立的目录,开启 innodb_file_per_table 参数时,逻辑上的表也对应独立的数据文件。
因此,MySQL 数据库中保存的所有数据不是保存在同一个文件中。
MySQL 的逻辑存储结构如下图所示,表空间(tablespace)是 InnoDB 存储引擎逻辑结构的最高层,表空间对应磁盘上的数据文件,所有的数据都存放在表空间中。
InnoDB 的 tablespace 的文件结构由段(sengment)、区(extent)、页(page)/块(block)组成。

其中,页是 InnoDB 磁盘管理的最小单元,也是数据加载单元。页中保存数据行记录。
InnoDB 存储引擎中,默认每个页的大小为16KB,通过 innodb_page_size 参数进行控制。
数据写入时先将数据页加载到内存中,更新后称为脏页,最后由后台线程刷盘落库。
MySQL 数据库内存中的数据结构是 B+ 树,用于组织行记录。实际上就是索引,因此每一个索引对应一棵 B+ 树。
其中 InnoDB 存储引擎中表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。
5.1.2 删除操作
具体到删除操作时,就可以引入物理删除与逻辑删除的概念了。
-
物理删除,将数据从硬盘删除,立即释放存储空间; -
逻辑删除,设置标志位表示已删除,不需要其他数据移动,由其他异步任务完成空间清理(通常称作 compact 操作)。
MySQL 中的 delete 操作正是逻辑删除,首先遍历索引,找到叶子节点上的目标索引页(加 exclusive latch),然后给索引页上对应的索引项设置 deleted 或 invalid 标签。
不过需要注意的是,在事务提交以后,MySQL 中的异步线程并不会进行空间清理,不过这些空间可以复用,包括记录的复用与数据页的复用。
因此,在插入操作时,首先会从PAGE_FREE
链表中尝试获取足够的空间,仅比较链表头的一个记录,如果这个记录的空间大于需要插入的记录的空间,则复用这块空间(包括heap_no
),否则就从PAGE_HEAP_TOP
分配空间。
由于仅比较链表的第一个记录,因此算法的空间利用率并不高。比如依次删除记录的大小为 4K、3K、5K、2K,只有当插入记录的大小小于 2K 时,被删除的空间才可以复用。假设新插入的记录大小为 0.5K,PAGE_FREE
链表头大小 2K,也只能复用 0.5K,剩下的 1.5K 依然将被浪费。下次插入只能利用 5K 记录所占的空间,并不会把剩下的 1.5K 也利用起来。
这些可以复用,但是没有被使用的空间,可以称为“空洞”。实际上,不止是删除数据会造成空洞,插入数据和更新数据也会。
如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。
另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值,因此也会造成空洞。
可见,一方面增删改都会造成空洞,另一方面空间复用的算法利用率不高,因此极端条件下甚至可能出现空洞大于数据的情况,可以通过重建表完成空间回收。
显然,逻辑删除的一个显著缺点是会造成空洞,可能导致存储空间没有及时释放。
那么,逻辑删除的优点是什么呢?主要包括以下几点:
-
简单高效。仅设置状态位,无其他不必要的数据移动; -
简化并发控制。不需要调整其他索引项的存储位置,减少了对并发任务的影响; -
保证事务正常回滚。被删除的数据行依然处于加锁(transactional lock)状态,直到事务提交才释放,其对应的索引项(处于逻辑删除状态)所占用的空间得以保持,如果事务回滚,不需要重新分配空间,能够快速完成回滚。
5.2 minidb
首先,不同于 MySQL 基于 B+ 树实现,minidb 基于 LSM 树实现。
LSM-Tree(Log Structured Merge Tree,日志结构合并树)
LSM 树的核心思想是顺序 IO 远快于随机 IO。
和 B+ 树不同,在 LSM 树中,数据的插入、更新、删除都会被记录成一条日志(称为 Entry),然后追加写入到磁盘文件当中,这样所有的操作都是顺序 IO。
可见,LSM 树写入都是顺序 IO,因此比较适用于写多读少的场景;而 B+ 树查询性能稳定,写入是随机 IO,而且可能触发页分裂和合并,因此适用于读多写少的场景。
minidb 中的删除操作并不会定位到原记录进行删除,还是将删除的操作封装成 Entry,追加到磁盘文件当中,只需要标识 Entry 的类型是删除。
而随着数据文件的持续写入,文件容量将无限增大,因此需要一个定时合并数据文件的操作,用于清理无效的 Entry 数据,该过程称为 merge。
merge 的思路也很简单,需要取出原数据文件的所有 Entry,将有效的 Entry 重新写入到一个新建的临时文件中,最后将原数据文件删除,临时文件就是新的数据文件了。

对比下 MySQL 与 minidb 中的删除操作。
MySQL 中需要定位到指定记录,然后进行逻辑删除,设置标志位表示已删除,存储空间可以复用,但是后台线程也不会回收空间,因此有可能导致大量空洞。
minidb 中不需要定位到指定记录,而是直接将操作封装后追加写入数据文件,其中标识操作类型是删除,定时合并数据文件过程中会最终删除对应记录。
minidb 的详细介绍后续将单独一篇讲解。
6 结论
TinyDB 的 JSONStorage 类的 write 方法中在数据刷盘以后调用 file.truncate() 方法,用于截断原始数据。
原因是每次都是从文件头重新写入,因此删除操作并不是真正的删除。如果新写入的数据长度小于老数据,那么新数据和部分老数据就会同时存在,最终导致写入报错 JSON 格式非法。
类似的,文件操作中如果在重复写入之前不移动文件指针的位置,也有可能导致写入覆盖。根据场景选择合适的文件写入模式可以避免该问题。
此外,MySQL 数据库中并不是每次从文件头重新写入,而是以数据页为单位进行操作,并通过 B+ 树组织行记录。删除操作也不是物理删除,而是逻辑删除,优点如简单高效,缺点如存储空间无法及时释放。
minidb 基于 LSM 树实现,增删改操作都被封装后追加写入数据文件,并标明操作类型,并且定时合并数据文件中的历史数据。
7 待办
-
mongodb 删除原理
参考教程
-
remove with json database results in json file with extra data at the end #1
https://github.com/msiemens/tinydb/issues/1
-
Python document: Built-in Functions
https://docs.python.org/3/library/functions.html#filemodes
-
数据库内核月报:Database · 理论基础 · 高性能B-tree索引
https://www.bookstack.cn/read/aliyun-rds-core/bec36ab745a61976.md#
-
MySQL Reference Manual: File-Per-Table Tablespaces
https://dev.mysql.com/doc/refman/5.7/en/innodb-file-per-table-tablespaces.html#innodb-file-per-table-advantages
-
MySQL 实战 45 讲:为什么表数据删掉一半,表文件大小不变?
https://time.geekbang.org/column/article/72388
-
数据库内核月报:MySQL · 引擎特性 · InnoDB 数据页解析
https://www.bookstack.cn/read/aliyun-rds-core/11fe5b46677ac594.md#
-
从零实现一个 k-v 存储引擎
https://mp.weixin.qq.com/s/s8s6VtqwdyjthR6EtuhnUA
原文始发于微信公众号(丹柿小院):Python TinyDB storage 中 truncate 的作用
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/178684.html