学习笔记:SQLite 内存缓存极简模型

2,763 阅读6分钟

上一篇 《KMM 求生日记四——使用 kotlinx.serialization 对 SQLite 反序列化》 介绍了如何从现有的 Android/iOS 双端的 SQLite 中反序列化出 Kotlin 对象。

SQLite 自移动开发诞生之初就一直陪伴开发者左右,除了 Realm 曾掀起一阵较大的波浪之外,其余的大部分主流 ORM 框架如 Android 上的 LitePal、Room,iOS 上的 FMDB,KMM 的 SQLDelight 都是基于 SQLite 开发。至于一些在 Android 上用 JDBC 连接 MySQL 之类的,终究属于 Geek 的自娱自乐,大规模生产使用的意义不大。

最近一周看了一本书:《The Definitive Guide to SQLite (Second Edition)》,虽然也是本 10 年前的老书了,但对于 SQLite 的学习也饶有帮助。本文是基于这本书的读书笔记。经过了 10 年的迭代,也许书中的部分内容已经过时,如果有过时的部分烦请不吝赐教。

SQLite 架构设计简介

SQLite 诞生于 2000 年,由 C 语言实现,无数据库用户密码,无独立进程,足够轻量(只有几百 KB)。虽然距今已有 20 多年,但它在不断地升级维护,最新版本 3.37.2 发布于 2022.1.6。SQLite 有许多设计精妙的内部实现,本文主要专注于简单讨论数据库 backend 以及内存缓存部分。

先看看 SQLite 的架构图:

image.png

用户经由 C API 输入的 SQL 语句经由编译器编译(词法分析器 -> 解析器 -> 代码生成器)完成编译后交由虚拟机(VDBE)执行。最终的数据操作由数据库后端(backend)负责。上图中的 Core 部分主要是 SQL 语句的编译执行,我们可以先放放,重点聚焦 backend。

Backend

许多开发者有一个误区,认为 SQLite 的每次 CRUD 操作都是在磁盘上进行的,所以 SQLite 的性能瓶颈之一就是每次 SQL 语句的执行都会进行 IO 操作。但实际上作为一款轻量级的嵌入式数据库 SQLite 当然是有内存缓存的。从上面的架构图可以看到,只有 Database 是真正存在于磁盘中,backend 通过调用操作系统 API(OS Interface)操作磁盘中的数据。而在内存中,SQLite 通过 B 树(B-Tree)与 Pager 组织缓存数据。B 树的每个节点我们称作数据库页(Page),每个页是大小相同的内存区域数据块。不过内存是宝贵的资源,一个数据库不可能把全部的数据缓存在内存,再加上内存只是临时缓存,最终还是要把数据记录到磁盘上,因此 B 树中的页是动态的,需要时而从磁盘中读取页数据,时而又将页数据写入磁盘。页的传输工作由 Pager 来完成,每当接收到 B 树的请求,Pager 就会通过 OS Interface 读取/写入页。B 树与 Pager 通过算法将使用频率高的页缓存在内存中,从而最大限度地减少 IO 操作以提高性能。

SQLite 使用享元模式,即使用一个链表收集废弃的页,用于页的重用,而不是每次都申请新的内存区域。

页缓存 size

在 SQLite 设计之初,单个页缓存的大小默认值是 1024 bytes,但 3.12.0 版本之后为充分利用现代硬件的性能,单个页缓存大小默认值已经提升至 4096 bytes(参考链接 1),而页数量默认值则一直为 2000。可以在 SQLite 编译时使用以下 C 编译器配置改变这二者的默认值:

-DSQLITE_DEFAULT_PAGE_SIZE=1024 
-DSQLITE_DEFAULT_CACHE_SIZE=2000

也可以在运行时通过 sqlite3_exec()  API 运行 PRAGMA 命令改变两者的数值。

我们什么时候应该调整页 size 或页数量?根据《The Definitive Guide to SQLite (Second Edition)》的说法:

And remember, you would only need to do this in environments where there are other connections using the database and concurrency is an issue. If you are the only one using the database, then it really wouldn’t matter.

即,只有在多个连接同时连接数据库且需要考虑到并发的状况时才有必要这么做,其他情况无需调整。

我在网上搜索时发现,一些文章提到在进行 SQLite 性能优化时也调整了页 size 与页数量,但几乎找不到关于这两个数值应该根据哪些因素进行计算,考虑到移动端 app 用户的手机型号各异,只能通过大量的性能测试或在 SQLite 数据库 create 之前根据不同手机计算出一个合理的数值。但调整后性能优化幅度大约有多少,以及其他的影响面有多大都是未知数,个人认为在深入的了解与测试之前不建议修改这两者的默认值。

共享缓存模式

每一个数据库连接都会创建自身的内存缓存,我们可以使用共享缓存模式使两个数据库连接共享同一个内存缓存,在 C API 中,我们需要使用 URI 来打开数据库,比如下面这样:

sqlite3_open_v2("file:/sqlitepath/my_cities.db?cache=shared", dbPtr.ptr, SQLITE_OPEN_READONLY or SQLITE_OPEN_URI, null)

注意,这行代码是用 Kotlin 直接调用 C 函数。我们通过在 URI 中添加 cache=shared 参数开启共享缓存模式。只要使用相同的 URI 建立新的数据库连接,就可以使两个连接共享同一个内存缓存从而节省大量内存。共享内存缓存的数据库也可以是内存数据库,例如下面这个 URI:

file:/sqlitepath/my_cities.db?mode=memory&cache=shared

它在 URI 中增加了 mode=memory 参数。

稍稍总结下以及画饼

本文主要是简述一下 SQLite 的内存缓存机制以及内存数据的管理方式,SQLite 的编译器,VDBE(虚拟数据库引擎,它也被称为 SQLite 的核心),WAL,类型亲缘性等内容都没提到,这些内容也都挺有意思的,就是其中有些部分太难,学起来烧脑细胞,我暂时也只浅尝辄止先不管了。

了解 SQLite 的内存缓存机制主要是为了横向比较多种本地存储方式,我们之前想用纯 KV 的存储框架(例如 MMKV)代替现有的 SQLite,但根据实际业务看下来,纯 KV 的查询方式比 SQL 功能还是差太远了。目前通过学习了解,个人感觉 SQLite 在 KMM 上仍然是个不错的选择,问题的主要矛盾点还是在于 KMM ORM 框架的设计、该 ORM 框架的用途范围(比如要不要给 Java 和 Objective-C/Swift 等主工程代码调用?),以及上层 API 够不够先进。

参考链接 1:www.sqlite.org/pgszchng201…