Wrapper’s Delight - The Trail of Bits博客
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后,我们对其可扩展性和功能丰富性印象深刻。它具备命令行接口便于调试和原型设计,能轻松处理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扩展时优先选择“外部”FTS表。插入数据后使用’rebuild’命令构建表(https://www.sqlite.org/fts5.html#the_rebuild_command)。
-
重用预处理语句。使用sqlite_prepare_v3()例程创建并传递SQLITE_PREPARE_PERSISTENT选项(https://www.sqlite.org/c3ref/prepare.html)。
-
通过sqlite3_bind_*()例程绑定文本和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具有很高的可扩展性。
如果您喜欢这篇文章,请分享至: Twitter LinkedIn GitHub Mastodon Hacker News