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扩展时,优先选择“外部”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
页面内容
细节 | SQLite性能调优 | SQLite C API技巧 | 最终思考
近期文章
构建安全消息传递很难:对Bitchat安全辩论的 nuanced 看法 | 使用Deptective调查您的依赖项 | 系好安全带,Buttercup,AIxCC评分轮正在进行中! | 使您的智能合约超越私钥风险 | Go解析器中意想不到的安全隐患
© 2025 Trail of Bits.
使用Hugo和Mainroad主题生成。