C++ 项目里什么时候会需要嵌入式数据库?我印象最深的一次是给一套工业检测设备写上位机——设备每秒钟要记录几十条传感器数据,现场没有服务器,又不能要求客户去装 MySQL 或者 PostgreSQL,而且数据量还没有大到要用消息队列去转存。当时第一反应是写文件,但很快发现文件格式换来换去,索引、去重、条件查询全部要自己造轮子,维护成本高得离谱。后来切到嵌入式数据库,选型、集成、调优一路走下来,才意识到这件事的核心从来不是“会调用几个 API”,而是选型逻辑、构建方式、并发模型和踩坑经验这四件事真正决定项目成败。这篇文章就把我在 C++ 里集成嵌入式数据库的完整思路和实操过程写出来,适合那些准备在客户端、边缘设备或单机工具里落数据存储的 C++ 开发者,也适合被“SQLite 一行代码接入”之类说法误导过、想知道背后到底有多少门道的人。
1. 选型不是只看热度:SQLite、DuckDB、LMDB 怎么选
很多人一想到嵌入式数据库就默认是 SQLite,这个方向没错,但不够准确。嵌入式数据库不是某一家产品的代名词,而是一类运行在进程内部、以文件或内存为主要存储介质的数据库统称。同样是嵌入式,SQLite、DuckDB、LMDB 三者的设计目标差异非常大,用错了场景会非常痛苦。选型的判断依据,首先不是“谁更流行”,而是你的数据到底是怎么被读写的。
1.1 先搞清自己的数据访问模型
我在选型时通常先问自己三个问题:
- 写入频繁还是读取频繁?写入量是每秒几条、几百条还是几万条?
- 查询模式是点查、范围扫描,还是大量聚合分析?
- 并发模型是单线程、多线程读,还是多线程同时读写?
这三个问题的答案基本能筛掉一半的候选库。
如果是高吞吐的键值读写,比如缓存、会话状态、键值对存储,优先考虑 LMDB 或 RocksDB 这类以 KV 模型为核心的嵌入式数据库。它们天生为快速读写设计,没有 SQL 解析开销,B+ 树或 LSM 树的结构决定了它们在随机读写上比行式存储的关系型数据库有优势。
如果数据是结构化表格、有复杂查询条件和事务要求,SQLite 是稳妥之选。它支持标准 SQL 的子集、支持事务、支持索引,而且单文件存储,备份迁移非常方便。
如果你拿到的数据是数百 GB 甚至 TB 级,且查询以“统计、聚合、分组”而不是“按主键取一行”为主,那该考虑的是 DuckDB 这类列式分析型数据库。它在单机进程内就能跑出类似数仓的性能,这一点 SQLite 很难做到。
1.2 三库核心参数对比
这里给一张我在选型时常用到的基础对比表,方便从整体参数上先做个预筛。
| 对比维度 | SQLite | DuckDB | LMDB |
|---|---|---|---|
| 存储模型 | 行式存储 | 列式存储 | 键值 B+ 树(内存映射) |
| 主要使用场景 | 关系型数据、事务、通用 CRUD | 数据分析、OLAP、大规模聚合查询 | KV 高速读写、缓存、大规模键值 |
| 单文件存储 | 是 | 是 | 否(一个目录 + 多个文件) |
| SQL 支持 | 完整 SQLite SQL 子集 | 支持性很高,接近 PostgreSQL 风格 | 无 SQL,KV API |
| 事务隔离 | 支持 ACID 事务 | 支持 ACID 事务 | 支持 ACID 事务 |
| 并发模型 | 单写多读,文件级锁 | 多版本并发控制,读写并行 | 读不阻塞,写串行 |
| 数据规模 | 通常适合 GB 级以内 | 适合 GB 到 TB 级分析 | 适合 GB 到 TB 级 KV |
| 集成难度 | 极低(单 C 文件) | 低(C++ 库可以直接链接) | 低(API 简单,零拷贝) |
这个表的重点不是参数抄写,而是帮助你建立第一层直觉:SQLite 是“单机版关系数据库”的平替,DuckDB 是“单机版数据仓库”的平替,LMDB 是“单机版 Redis + 持久化”的平替。拿 SQLite 去做海量聚合,拿 DuckDB 去做高频小事务,拿 LMDB 去做复杂 SQL 查询,都是事倍功半。
1.3 其他值得留意的候选库
除了上面三个,实际工程里还会遇到 LevelDB 和 RocksDB。LevelDB 是 Google 开源的 LSM 架构 KV 库,RocksDB 是它的高并发强化版。如果你的写入量极大,且需要批量顺序写能力,这类 LSM 结构的库比 LMDB 更合适,因为 LSM 将随机写转化为顺序写,磁盘密集场景下优势很明显。但反过来,它的读放大和空间放大问题是需要调优的,而且没有 SQL 语法层,所有查询逻辑都要自己处理。
从我个人的经验来看,如果你没有明确的高吞吐 KV 或者大规模分析需求,SQLite 仍然是默认答案。它经过了 20 多年的极端环境验证,稳定性、兼容性和工具链完备度是所有嵌入式数据库里最成熟的,C++ 生态里围绕它的封装库也最丰富。选它至少不会踩到“选错方向”这种大坑。
2. 编译和链接:嵌入式数据库集成的第一道坎
选型确定之后,第一个真正给你“下马威”的往往不是 API,而是编译链接。嵌入式数据库的集成方式和普通第三方库不太一样:它们以源码或静态库形态为主,极少有动态库安装版的“标准做法”。这一步没处理好,后续所有代码都跑不起来。
2.1 SQLite 源码编译时的几个关键开关
SQLite 官方推荐的集成方式是直接使用合并版的sqlite3.c和sqlite3.h。这个合并版把整个数据库引擎打包成一个 C 文件,编译时只需要把它跟你的工程一起编译,或者先编成静态库再链接,非常省心。
但如果你直接按默认参数编译,会遇到两个隐患:
- 默认的
SQLITE_THREADSAFE状态是 1,也就是启用线程安全模式,但这只是保证 SQLite 内部结构在并发访问时不会崩溃,不代表你的业务并发一定正确。如果项目确定是单线程,可以用SQLITE_THREADSAFE=0去掉互斥锁的开销。 - 默认没有启用
SQLITE_ENABLE_FTS5全文检索扩展,如果业务里想做文本搜索,编译时必须手动加这个宏。
一个我在生产环境用过的编译参数组合如下:
gcc -c sqlite3.c -O2 -DSQLITE_THREADSAFE=1 \ -DSQLITE_ENABLE_FTS5 \ -DSQLITE_ENABLE_COLUMN_METADATA-DSQLITE_ENABLE_FTS5开启全文索引能力,-DSQLITE_ENABLE_COLUMN_METADATA开启列元数据查询,某些框架和工具库会依赖它。如果你的项目没有这些需求,可以不加,保持最小化最稳妥。
2.2 CMake 集成模板
C++ 项目现在基本都用 CMake 管理构建,集成嵌入式数据库最干净的姿势是直接把源码放进工程做静态编译,而不是去系统里找libsqlite3.so碰运气。系统自带的版本可能很老,可能没启用你需要的扩展,还可能链到动态库后造成部署时“缺 so”的问题。
一个高效的 CMake 集成方式是使用 FetchContent,直接从官方源拉取 SQLite,并在构建时带上需要的宏:
cmake_minimum_required(VERSION 3.14) project(embedded_db_demo CXX) set(CMAKE_CXX_STANDARD 17) include(FetchContent) FetchContent_Declare( sqlite3 URL https://www.sqlite.org/2024/sqlite-amalgamation-3450300.zip ) FetchContent_MakeAvailable(sqlite3) add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE sqlite3) target_include_directories(myapp PRIVATE ${sqlite3_SOURCE_DIR})FetchContent 的好处是构建可复现,团队里任何人拉下来都能编,不需要手动安装第三方库。如果公司内网不允许直接访问外网,可以提前把压缩包下载到本地,把 URL 改成file://协议路径。
2.3 IDE 与编译环境
热搜词里“vscode c++”、“vscode 配置 c/c++ 环境”出现频率很高,这类问题我在集成嵌入式数据库时也碰到过。其实纯命令行运行时很顺畅,但一打开 VS Code 就报“找不到头文件”或者“无法解析的外部符号”,大概率是以下原因之一:
c_cpp_properties.json里includePath没有把sqlite3.h所在的目录加上。- C++ 扩展默认用的编译器和你终端里的编译器不是同一个。
- 使用 CMake 时,VS Code 没有配置好 CMake Tools 插件,导致它找不到 CMake 生成的编译数据库。
我一般会在项目根目录放一个.vscode/c_cpp_properties.json,明确指定编译器和 include 路径:
{ "configurations": [ { "name": "Linux", "includePath": [ "${workspaceFolder}/**", "${workspaceFolder}/third_party/sqlite3" ], "compilerPath": "/usr/bin/g++", "cStandard": "c17", "cppStandard": "c++17", "intelliSenseMode": "linux-gcc-x64" } ], "version": 4 }顺手说一句,如果 Linux 下终端能编译但 VS Code 不能,基本是环境变量差异导致的,建议在 VS Code 里使用 CMake 预设(Preset)统一配置,而不是手动敲命令。
3. SQLite C++ 集成实例:能跑通的项目才叫集成
工具链搞定以后,进入最实际的部分:把数据库真正用起来。很多教程喜欢直接丢给你一个 ORM 封装类的用法,看起来很高端,但一旦出了问题,你对底层发生了什么完全没概念。我的做法是:先从 C API 开始,跑通最原始的数据流,再用 C++ 封装库提高效率。
3.1 从 C API 开始:连接、建表、写读
SQLite 的 C API 核心对象只有几个:sqlite3(数据库连接)、sqlite3_stmt(预编译语句)。一段最基础的完整流程如下:
#include <sqlite3.h> #include <iostream> #include <string> int main() { sqlite3* db = nullptr; int rc = sqlite3_open("demo.db", &db); if (rc != SQLITE_OK) { std::cerr << "open failed: " << sqlite3_errmsg(db) << std::endl; sqlite3_close(db); return 1; } const char* create_sql = R"( CREATE TABLE IF NOT EXISTS sensor_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, sensor_id INTEGER NOT NULL, value REAL NOT NULL, ts INTEGER NOT NULL ); )"; char* errmsg = nullptr; rc = sqlite3_exec(db, create_sql, nullptr, nullptr, &errmsg); if (rc != SQLITE_OK) { std::cerr << "create failed: " << errmsg << std::endl; sqlite3_free(errmsg); } sqlite3_stmt* stmt = nullptr; const char* insert_sql = "INSERT INTO sensor_data(sensor_id, value, ts) VALUES(?, ?, ?);"; rc = sqlite3_prepare_v2(db, insert_sql, -1, &stmt, nullptr); if (rc != SQLITE_OK) { std::cerr << "prepare failed: " << sqlite3_errmsg(db) << std::endl; sqlite3_close(db); return 1; } sqlite3_bind_int(stmt, 1, 1001); sqlite3_bind_double(stmt, 2, 23.45); sqlite3_bind_int64(stmt, 3, 1710000000); rc = sqlite3_step(stmt); if (rc == SQLITE_DONE) { std::cout << "insert ok" << std::endl; } sqlite3_finalize(stmt); sqlite3_close(db); return 0; }这里有几个新手容易忽略的细节。sqlite3_prepare_v2的第四个参数是传出语句句柄,第五个参数可以传一个const char**来获取剩余未解析的 SQL 文本,调试时很有用。sqlite3_bind_*系列函数的第二个参数是从 1 开始的索引,不是 0,搞错了很容易插入脏数据。每次sqlite3_step执行完,必须sqlite3_reset或sqlite3_finalize释放语句,否则会白白占用内存。
3.2 用 C++ 封装库提高效率
直接写 C API 能让你理解底层,但生产代码里逐行bind也太累了。我在正式项目中更常用的是 C++ 封装库,比如 SQLiteCpp 或 sqlite_modern_cpp。以 SQLiteCpp 为例,同样功能代码量能少一半:
#include <SQLiteCpp/SQLiteCpp.h> #include <iostream> struct SensorRecord { int sensor_id; double value; int64_t ts; }; int main() { try { SQLite::Database db("demo.db", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); db.exec("CREATE TABLE IF NOT EXISTS sensor_data (" "id INTEGER PRIMARY KEY AUTOINCREMENT," "sensor_id INTEGER NOT NULL," "value REAL NOT NULL," "ts INTEGER NOT NULL)"); SQLite::Statement insert(db, "INSERT INTO sensor_data(sensor_id, value, ts) VALUES(?, ?, ?)"); insert.bind(1, 1002); insert.bind(2, 26.78); insert.bind(3, static_cast<int64_t>(1710000100)); insert.exec(); SQLite::Statement query(db, "SELECT sensor_id, value, ts FROM sensor_data WHERE sensor_id = ?"); query.bind(1, 1002); while (query.executeStep()) { int sid = query.getColumn(0).getInt(); double val = query.getColumn(1).getDouble(); int64_t ts = query.getColumn(2).getInt64(); std::cout << "sensor=" << sid << " value=" << val << " ts=" << ts << std::endl; } } catch (const std::exception& e) { std::cerr << "exception: " << e.what() << std::endl; return 1; } return 0; }SQLiteCpp 最大的好处是自动管理sqlite3_stmt*的生命周期,析构时自动finalize,不容易内存泄漏。它还提供SQLite::Transaction类,用 RAII 方式管理事务,作用域内正常结束自动提交,发生异常自动回滚,这是裸写 C API 时最容易出错的地方。
3.3 事务与批量写入的性能差异
嵌入式数据库集成里最常被忽视的性能瓶颈,就是逐条提交。SQLite 默认是 autocommit 模式,每一条 INSERT 都是一次独立事务,每条都要经历磁盘同步。我在开发一个日志采集工具时做过对比:5000 条数据逐条 INSERT 耗时约 800 毫秒,而包进一个事务后只需要 40 毫秒左右,差距接近 20 倍。
批量写入的标准姿势是手动管理事务边界:
SQLite::Database db("demo.db", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); SQLite::Transaction transaction(db); SQLite::Statement insert(db, "INSERT INTO sensor_data(sensor_id, value, ts) VALUES(?, ?, ?)"); for (int i = 0; i < 10000; ++i) { insert.bind(1, i % 100); insert.bind(2, i * 0.5); insert.bind(3, 1710000000 + i); insert.exec(); insert.reset(); // 注意:exec 后必须 reset 才能重新 bind } transaction.commit();不要每次都 new Statement,复用同一个 Statement 并做 reset 是性能关键。因为prepare要解析 SQL 并生成执行计划,而reset只是把语句状态恢复到可执行位置,开销小得多。
4. 踩坑实录:并发、锁、文件损坏与 SQLITE_BUSY 那些事
嵌入式数据库集成的真正分水岭,是项目进入并发阶段以后。单线程写读跑得飞起,一旦多线程一上,各种诡异问题就全冒出来了。下面几个坑都是我在实际项目里遇到并排查过的。
4.1 SQLITE_BUSY 到底怎么处理
SQLite 的锁粒度是数据库级别的。同一时刻只允许一个写事务,写事务会阻塞其他写操作,同时会阻塞读,除非你开启了 WAL 模式。
第一次踩这个坑时,我的程序是两个线程,一个负责从采集卡取数据写库,一个负责响应界面查询。结果跑不到五秒,查询线程就开始抛database is locked。原因很简单:写入线程开了一个长事务没提交,查询线程去读,直接撞锁。
排查思路是这样的:
- 先检查是否开启了 WAL 模式。默认的 rollback journal 模式读写互斥,换 WAL 后读不阻塞写,写不阻塞读。
- 给连接设置
busy_timeout,避免拿到锁失败后立刻报错返回。 - 最后才考虑调整业务并发结构,比如串行化写操作。
开启 WAL 模式非常简单:
db.exec("PRAGMA journal_mode=WAL;"); db.exec("PRAGMA busy_timeout=5000;");开启 WAL 之后,同一个数据库会出现demo.db-wal和demo.db-shm两个附加文件。很多人备份时只拷走了demo.db,恢复后发现数据不全,就是这个原因。备份时要把三个文件一起处理,或者执行一次PRAGMA wal_checkpoint(FULL);把 WAL 内容合并进主库再拷主文件。
4.2 连接是否要跨线程共享
SQLite 的线程安全模式允许把同一个连接在不同线程间使用,但前提是开启了SQLITE_THREADSAFE=1,并且操作时数据库内部会有互斥锁保护。实际工程里我基本不让连接跨线程共享,因为即使 SQLite 内部不崩溃,查询和写的事务边界也非常容易被你无意识地交叉破坏。
比较稳妥的方案是“每线程独立连接”,或者直接维护一个连接池。读操作可以去任意连接,写操作统一交给一个专用的写线程。写线程通过队列接收写入任务,其他线程把数据 push 到队列即可。这个模型能避免绝大多数锁冲突,而且逻辑清晰。
日志采集工具用这个模型跑了一个月,再没出现过SQLITE_BUSY。
4.3 文件损坏的常见原因
嵌入式数据库的文件损坏问题,往往比 SQLite 的 bug 更常见的是使用层面的误操作。我遇到过几个典型场景:
- 直接剪切/复制数据库文件的同时,数据库进程还在写入,导致文件不一致。
- 数据库文件被同步网盘软件(Dropbox 之类)同步了 WAL 文件的中间状态。
- 程序被
kill -9或taskkill /F强杀,没有经过 SQLite 的恢复流程。
SQLite 本身有崩溃恢复机制,下次打开时会自动回滚未提交事务,但这种恢复依赖一个前提:数据库文件本身没有被程序层面的不当操作破坏。如果你需要拿数据库文件做复制或备份,先走正常的 checkpoint,或者用 SQLite 的 online backup API 来做。
C API 提供的sqlite3_backup_init可以安全地备份一个正在使用的数据库:
sqlite3* src; sqlite3* dst; sqlite3_open("demo.db", &src); sqlite3_open("backup.db", &dst); sqlite3_backup* backup = sqlite3_backup_init(dst, "main", src, "main"); if (backup) { sqlite3_backup_step(backup, -1); sqlite3_backup_finish(backup); }4.4 事务嵌套和异常回滚
C++ 异常安全和事务是天然矛盾的一对。如果你在try块里开启了事务,中途抛异常,结果没到commit就跳出去了,事务会一直挂着直到连接关闭。所以在封装库选型上,我特别看重它是否支持 RAII 管理事务。
SQLiteCpp 的Transaction类,析构时如果还没 commit 会自动 rollback,这能避免绝大多数“事务悬挂”问题。裸写 C API 时,你一定要自己在析构函数或异常捕获路径里处理sqlite3_exec(db, "ROLLBACK", ...)。
还要注意:SQLite不支持嵌套事务。你在一个事务里再执行BEGIN,它会悄悄地把当前事务提交掉。如果代码里不小心写了嵌套 BEGIN,数据可能在你没预料到的时机就落盘了。真要嵌套控制粒度,应该使用SAVEPOINT。
SQLite::Transaction tx(db); db.exec("SAVEPOINT sp1;"); // 做一些可能失败的操作 db.exec("ROLLBACK TO sp1;"); db.exec("RELEASE sp1;"); tx.commit();5. 从 SQLite 到更宽的嵌入式数据库图谱
SQLite 够用,但不代表它是唯一答案。实际接线上游经常出现两种诉求:一是分析能力不够,二是数据加密不好解决。这两个问题分别把我带到了 DuckDB 和 SQLCipher 面前。
5.1 什么时候从 SQLite 换到 DuckDB
如果你的 C++ 程序要处理“百万行以上数据的聚合统计”,比如日志分析、数仓查询、特征工程数据筛选,SQLite 的性能会明显拖后腿。SQLite 是行式存储,聚合查询要把整行数据读出来再逐行计算,数据量大时 IO 和 CPU 都吃不消。DuckDB 是列式存储,只读参与计算的列,还自动做向量化执行和并行扫描。
我在一个离线数据处理工具里遇到过这样的场景:源数据是一份 300 万行 CSV,要做分组统计和窗口函数计算。用 SQLite 方式做,单次查询要几十秒;换成 DuckDB,直接原生查询 CSV 文件和 Parquet 文件,同样的统计在 2 秒内完成。
DuckDB 的 C++ 集成方式和 SQLite 非常相似,头文件加库,直接调用。它也支持类似 SQLite 的语句参数绑定:
#include "duckdb.hpp" int main() { duckdb::DuckDB db(nullptr); // 内存模式 duckdb::Connection con(db); con.Query("CREATE TABLE t AS SELECT * FROM read_csv_auto('large.csv')"); auto result = con.Query("SELECT category, COUNT(*), AVG(value) FROM t GROUP BY category"); if (!result->HasError()) { for (auto& row : *result) { std::cout << row.GetValue<std::string>(0) << ": " << row.GetValue<int64_t>(1) << ", " << row.GetValue<double>(2) << std::endl; } } return 0; }DuckDB 对 C++ 的友好度不输 SQLite,如果你是做嵌入式分析型功能,完全值得列入备选。
5.2 数据库加密不是“加个密码”那么简单
SQLite 原版没有任何加密能力。“给数据库文件设密码”不是内置功能,网上很多教程让你直接改PRAGMA key=xxx,那是 SQLCipher 的行为,不是 SQLite 官方行为。如果你直接对一个原版 SQLite 数据库执行PRAGMA key,它会当作未知参数忽略,数据仍然明文存储。
如果你的数据有敏感信息,方案有三个:
- 使用 SQLCipher:它是 SQLite 的开源加密分支,加密粒度是整页加密,通过 C API 集成时使用
sqlite3_key()传入密钥后正常操作。 - 使用商业版 SQLite SEE。
- 在应用层加密字段,绕开数据库加密问题,但查询条件如果是密文,就无法用索引,性能损失大。
在 C++ 里用 SQLCipher 集成并不复杂,编译时把sqlite3.c换成 SQLCipher 的同名文件,然后在打开数据库后立即调用:
sqlite3* db = nullptr; sqlite3_open("secure.db", &db); int rc = sqlite3_key(db, "your-passphrase", 15); if (rc != SQLITE_OK) { std::cerr << "key init error" << std::endl; }密钥管理才是真正的难点。硬编码在 C++ 二进制里并不安全,逆向工程很容易提取。在 Windows 上可以用 DPAPI,在 Linux 上可以用 libsecret 或者 ekmsolution,至少不要让密钥以明文形式出现在配置文件里。
5.3 C++ 集成的整体工程体会
最后聊一点比较虚但很实际的东西。集成嵌入式数据库,技术选型也好、踩坑排查也好,最终拼的是“最小依赖”和“可复现构建”这两件事。
嵌入式数据库最大的优势就是没有独立服务进程,整个数据库引擎缝进你的程序里,部署时只需要拷贝一个可执行文件,不需要安装数据库软件。这个优势只有在你的构建系统足够干净时才能发挥出来。我见过很多项目,一开始图方便用系统自带的libsqlite3-dev,后来代码被部署到客户环境时,发现系统库版本老旧,部分 SQL 语法不支持,又回头改成源码编译,白白浪费了一个星期。
我的建议是,从一开始就把嵌入式数据库的源码作为第三方依赖纳入 CMake 管理,版本锁定、编译选项明确、开箱即用。团队成员换电脑、客户现场部署、CI 构建都能复现同样结果,这些看起来麻烦的事,会在项目后期带来巨大的回报。
6. 一次完整的排查案例:设备日志库“每次重启就丢最后一批数据”
选型、编译、API、并发都聊过之后,分享一个我印象最深的真实排查过程,作为全文收尾。这套代码在一台边缘网关上跑,负责缓存传感器日志,日志量不大,但要求不能丢数据。测试阶段一切正常,部署一段时间后客户反映:设备每次重启,最后一批日志总是丢。
第一次怀疑是 WAL 文件没合并,但检查发现之前已经做过wal_checkpoint,重启后数据还是在丢。后来才发现问题根本不在数据库层,而在应用层:我把数据写入了一个内存队列,达到一定条数才批量写库。设备断电时,队列里还没落盘的数据自然就丢了。
这个问题的本质是“业务缓存 + 持久化”的边界没划清楚。嵌入式数据库不是消息队列,它不是为“先存内存、最终一致”设计的,SQLite 比大部分业务更希望数据及时落盘。
最终修复方案也很简单:改成了“每来一条数据立即写库,但通过小事务批量提交”的策略,把单条 INSERT 合并成每 50 条一个事务提交。既保持了写入吞吐,又保证任何时刻最多丢 50 条,结合设备本身的看门狗机制,客户可接受,问题彻底解决。
把一个数据库引擎嵌进 C++ 程序,真正的难点从来不是 API 调用,而是你怎么理解数据生命周期、并发边界和部署环境。把这几个问题想透了,SQLite、DuckDB、LMDB 都只是工具,随取随用。