现代C++打造轻量级高性能SQLite封装器

本文详细介绍基于现代C++17特性开发的SQLite封装器,支持编译时查询优化、线程局部连接管理和预处理语句缓存,在750行代码内实现高性能数据库操作接口,包含实用SQLite调优技巧和API使用建议。

Wrapper’s Delight - The Trail of Bits博客

Patrick Palka,伊利诺伊大学芝加哥分校
2019年8月26日
engineering-practice, internship-projects

今年夏天在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性能的调优技巧。我们的封装器会自动为您完成前三点。

  1. 使用SQLite的FTS扩展时,优先选择“外部”FTS表。在插入数据后使用’rebuild’命令构建表(https://www.sqlite.org/fts5.html#the_rebuild_command)

  2. 重用预处理语句。使用sqlite_prepare_v3()例程创建并传递SQLITE_PREPARE_PERSISTENT选项(https://www.sqlite.org/c3ref/prepare.html)

  3. 通过sqlite3_bind_*()例程绑定文本和blob值时使用SQLITE_STATIC选项。确保底层内存在首次调用sqlite3_step()之前有效。这避免了文本或blob数据的冗余复制(https://www.sqlite.org/c3ref/bind_blob.html)

  4. 尽可能批量执行插入和更新操作。相对于使用单位大小事务,加速比随事务大小近乎线性增长,因此每事务插入10,000行比每事务插入1行快数千倍

  5. 在插入大部分或全部数据后创建索引,并明智选择索引。一次性创建索引总体上比随着更多数据插入不断构建和重建索引更快。使用SQLite命令行界面仔细检查每个查询的计划,并安装日志回调以便SQLite在决定创建临时索引时通知您

  6. 不要并发使用相同的数据库连接或预处理语句对象。SQLite对这些对象的访问进行序列化(https://sqlite.org/threadsafe.html)。此外,ACID的隔离性仅在同一数据库的不同连接之间保证(https://www.sqlite.org/isolation.html)

  7. 在内存中存储临时表和数据结构时,考虑使用pragma temp_store = memory(https://www.sqlite.org/pragma.html#pragma_temp_store)

SQLite C API使用技巧

最后,这里有一些简化SQLite C API使用的杂项技巧,其中前两点由我们的封装器为您完成。

  1. 安装支持并发的sqlite_busy_handler,避免在每次API调用后检查SQLITE_BUSY(https://www.sqlite.org/c3ref/busy_handler.html)

  2. 设置日志回调以便自动打印错误和其他通知,例如添加索引的提示(https://www.sqlite.org/errlog.html)

  3. 将SQLite副本捆绑到您的项目中。这是在应用程序中使用SQLite的推荐方式(https://www.sqlite.org/custombuild.html)。这样做还可以启用SQLite的全文搜索扩展和其他默认禁用的有用扩展

  4. 使用C++11原始字符串字面量格式化查询字符串

总结思考

今年夏天,我的一些收获是:当本地存储中等数量的结构化数据且没有大量并发需求时,您迟早会希望对这些数据执行复杂查询。除非从一开始就对数据访问模式有清晰规划,否则每当数据访问模式发生变化时,使用键值数据库很快就会让您陷入困境。另一方面,关系数据库可以轻松使您的数据库适应不断变化的访问模式。最后,现代C++有助于使与SQLite和其他C API的接口简洁易用,而经过适当配置后,SQLite具有相当的可扩展性。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计