Python TinyDB storage 中 truncate 的作用

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)组成。

Python TinyDB storage 中 truncate 的作用
微信图片_20221021151753

其中,页是 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 重新写入到一个新建的临时文件中,最后将原数据文件删除,临时文件就是新的数据文件了。

Python TinyDB storage 中 truncate 的作用
图片

对比下 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

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!