Wrapper’s Delight - The Trail of Bits Blog
Patrick Palka, 伊利诺伊大学芝加哥分校
2019年8月26日
工程实践, 实习项目
今年夏天在Trail of Bits实习期间,我充分利用最新C++语言特性从头构建了一个全新的SQLite封装器,该封装器易于使用、轻量级、高性能且支持并发——全部代码不足750行。该封装器已在Apache 2.0许可证下发布於https://github.com/trailofbits/sqlite_wrapper,欢迎提交评论和拉取请求。
开发动机
开发此SQLite封装器的动机源自一个基於Clang的内部代码审计工具项目,该工具使用数据库存储和查询源代码的语义信息。最初选择RocksDB作为底层数据库,但随着查询变得复杂,我很快发现键值数据库的刚性限制。渴望使用更具表达力的关系数据库,我开始探索转向SQLite。
初步实验表明,如果数据库经过适当调优(详见下文),切换不会降低性能,於是我开始研究现有的C++ SQLite封装器,看看是否有能满足我们需求的方案。我们需要一个既能执行原始SQL查询,又能满足轻量级和并发处理要求的封装器。遗憾的是,现有封装器均无法完全满足这些条件,因此我决定从头开始编写。
将后端迁移到SQLite后,我们对SQLite的可扩展性和功能丰富性印象深刻。它具备命令行界面方便调试和原型设计,能轻松处理100GB级别的数据库,甚至内置全文搜索(FTS)扩展。此封装器也让C++与SQLite的交互变得极其简单。
技术细节
使用示例可参考https://gist.github.com/patrick-palka/ffd836d0294f71d183f4199d0e842186。为简化操作,我们将参数和列绑定建模为可变参数函数调用,从而实现一次性指定所有绑定。
您将注意到的一些现代C++特性包括:内联和模板变量、constexpr if和auto模板参数、泛型lambda、折叠表达式以及thread_local变量。
核心功能
-
支持用户定义的序列化和反序列化钩子
(https://gist.github.com/patrick-palka/d22df0eceb9b73ed405e6dfec10068c7) -
支持将C++函数自动编组为SQL用户函数
(https://gist.github.com/patrick-palka/00f002c76ad35ec55957716879c87ebe)
连接管理
无需显式初始化数据库或连接对象。由於封装器利用thread_local对象管理数据库连接,在首次使用连接前会隐式建立连接,在线程退出时断开连接。
模板化设计
数据库名称和查询字符串作为模板参数而非函数参数传递。这在编译时实现了按数据库划分的thread_local连接对象与按数据库和查询字符串划分的thread_local预处理语句缓存之间的分离。此设计决策不鼓励使用动态生成的查询字符串,因为非类型模板参数必须是编译时常量表达式。在需要动态生成数据库名或查询字符串的情况下,封装器支持传递lambda表达式,在运行时构建并返回查询字符串。
性能优化
此封装器缓存并重用每个预处理语句,因此在程序生命周期内对sqlite3_prepare的调用次数达到绝对最小值。
局限性
此封装器不能用于手动管理数据库连接。当前使用thread_local对象处理连接,因此在给定线程上执行首次查询前创建连接,在线程退出时销毁。如果您需要对SQLite数据库连接和断开时间进行细粒度控制,此封装器可能不太适用,但此限制未来可能会改进。
SQLite性能调优
以下是一些最大化SQLite性能的调优技巧,我们的封装器会自动为您完成前三点:
-
使用SQLite FTS扩展时优先选择"external" FTS表
在插入数据后使用’rebuild’命令构建表
(https://www.sqlite.org/fts5.html#the_rebuild_command) -
重用预处理语句
使用sqlite_prepare_v3()例程创建并传递SQLITE_PREPARE_PERSISTENT选项
(https://www.sqlite.org/c3ref/prepare.html) -
绑定文本和blob值时使用SQLITE_STATIC选项
确保底层内存在首次调用sqlite3_step()前保持有效,避免文本或blob数据的冗余复制
(https://www.sqlite.org/c3ref/bind_blob.html) -
批量事务处理
尽可能在批量事务中执行插入和更新操作。相对于单位大小事务的速度提升随事务大小近乎线性增长,因此每事务插入10,000行比每事务插入1行快数千倍。 -
明智创建索引
在插入大部分或全部数据后创建索引,且选择索引要明智。一次性创建索引总体上比随数据插入不断构建和重建索引更快。使用SQLite命令行界面仔细检查每个查询的计划,并安装日志回调让SQLite在决定创建临时索引时通知您。 -
避免并发使用相同连接
不要并发使用相同的数据库连接或预处理语句对象。SQLite会序列化对这些对象的访问
(https://sqlite.org/threadsafe.html)。此外,ACID的隔离性仅在同一数据库的不同连接之间得到保证
(https://www.sqlite.org/isolation.html)。 -
内存临时存储
在内存中存储临时表和数据结枃时考虑使用pragma temp_store = memory
(https://www.sqlite.org/pragma.html#pragma_temp_store)。
SQLite C API实用技巧
最后是一些简化SQLite C API使用的杂项技巧,其中前两点由我们的封装器自动完成:
-
安装并发友好的sqlite_busy_handler
避免在每次API调用后检查SQLITE_BUSY
(https://www.sqlite.org/c3ref/busy_handler.html) -
设置日志回调
自动打印错误和其他通知(如添加索引的提示)
(https://www.sqlite.org/errlog.html) -
项目内集成SQLite
这是应用程序中使用SQLite的推荐方式
(https://www.sqlite.org/custombuild.html)。这样做还能让您启用SQLite的全文搜索扩展及其他默认禁用的有用扩展。 -
使用C++11原始字符串字面量格式化查询字符串
总结思考
今年夏天的收获之一是:当本地存储中等量的结构化数据且没有高并发需求时,迟早会需要对这些数据执行复杂查询。除非从一开始就对数据访问模式有清晰规划,否则每当数据访问模式发生变化时,使用键值数据库很快就会陷入困境。相反,关系数据库能轻松让您的数据库适应不断变化的访问模式。最后,现代C++有助於使与SQLite及其他C API的交互简洁易用,而经过适当配置后,SQLite具有相当好的可扩展性。