SQLite 在嵌入式 Linux 中的工程实践——轻量级本地数据持久化
SQLite 在嵌入式 Linux 中的工程实践——轻量级本地数据持久化
1
2
3
▎ 作者:嵌入式系统开发工程师
▎ 日期:2026-05-4
▎ 代码基础:储能管理系统(BESS)嵌入式控制器
1 | ▎ 作者:嵌入式系统开发工程师 |
一、引言:为什么是 SQLite,而不是文件或 Redis?
在嵌入式 Linux 平台上做持久化存储,工程师面临的第一个抉择往往是:用什么?
纯文件(CSV、JSON、二进制 dump)?太脆弱,掉电一致性无法保证,查询效率低下。
Redis?内存占用过高,且需要额外部署,在 AM335x、RK3566 这类只有 256MB~512MB RAM 的板子上几乎不可能。
完整关系型数据库(MySQL、PostgreSQL)?更不用想,光是进程本身就够呛。
我们最终选择了 SQLite——不是因为它”轻量”这个标签,而是因为它在嵌入式场景下具备了其他方案难以同时满足的三个属性:原子性事务、自包含单文件存储、零运维。
本文将结合我们储能管理系统(BESS)的真实代码,深入拆解 SQLite 在嵌入式 Linux 工程实践中的核心问题——WAL 模式选择、多线程安全策略、掉电安全写入、历史数据老化,以及如何将 SQLite 打造成本地 AI 知识库。
二、架构背景:SQLite 在系统中扮演什么角色
先从整体看。我们的系统由三层构成:
1 |
|
SQLite 在这个架构中承担了四类职责:
- 配置持久化:用户修改的参数设置(Data_SavedSettings)
- 历史数据归档:BMS 健康数据、实时运行数据、电量统计(Data_BMSHealthData、Data_ESStats_Daily等)
- 告警事件记录:告警起止时间、状态变更(Data_Alarms、Data_Events)
- MQTT 离线缓存:断网时临时存储待上报消息
三、单连接 + 互斥锁:我们的多线程安全策略
3.1 为什么不用连接池?
在 PC 端或服务器端,面对高并发数据库访问,工程师通常会选择连接池(Connection Pool)——维护一组数据库连接,按需分配给各线程,以减少连接创建销毁的开销。
但在嵌入式平台上,这个方案有致命问题:
- 内存开销:每个 SQLite 连接都会分配 page_cache(默认 2MB)、schema 解析缓存等,连接池维护 N 个连接意味着 N 倍的内存占用
- 并发控制复杂性:SQLite 在 WAL 模式下虽然支持并发读,但写操作仍然互斥,连接池带来的多写竞争需要额外的上层协调逻辑
- flash 寿命:多连接意味着更多 page 刷写,加速 eMMC/NAND flash 磨损
我们的选择是单一全局连接 + 互斥锁:
1 | // 所有数据库操作的入口 |
整个系统中,无论哪个线程(DPR 线程、Sampler 控制线程、MQTT Reporter 线程),访问 SQLite 的唯一合法路径就是这对函数。以 st_batt_testmode.c 中的 ClearTestHisData() 为例:
1 | void ClearTestHisData(STBATT_SAMPLER_ROUGH* pSTBatt) |
这个函数在测试模式启动时调用,目的是清除上次测试留下的残留数据。注意它连续执行了 15 条 DELETE,全程持有锁。这是有意为之的——原子性。如果在删到第 7
张表时被中断,数据库会处于部分清除的不一致状态,下次测试的结果将无法置信。
3.2 单连接模型的锁持有时间控制
单连接模型最大的陷阱是锁持有时间。如果某个线程在 Open_Lock_SqliteDB() 之后执行了耗时操作(如大量计算或网络等待),其他所有需要数据库的线程都会被阻塞。
在 dpr_loader.c 的 Save_SettingParam() 中,可以看到”即写即关”的模式:
1 | static BOOL Save_SettingParam(int nDevId, int nParamId, BYTE* pbyVal, int nValSize, sqlite3* data_db) |
注意这个函数接收 data_db 作为参数——调用者已经持锁,这个函数只负责”做完就走”,不做任何额外等待。
3.3 连接池方案的对比思考
如果这个系统要演化到更高并发(假设未来需要同时支持 Web API、OTA 更新、MQTT 上报并发写入),单连接模型的瓶颈会暴露。此时可以考虑 WAL 模式下的读写分离连接池:
写连接(单一): 所有 INSERT/UPDATE/DELETE 经此连接串行执行
读连接(N 个): SELECT 查询可并发,WAL 模式下不阻塞写连接
但在我们当前的 AM335x 平台上,这是不必要的复杂性。工程实践的核心原则:不过度设计。 单连接模型简单、可靠、可预测,适合当前规模。
四、WAL 模式 vs Journal 模式:嵌入式的选择逻辑
4.1 默认 Journal 模式的工作原理
SQLite 默认使用 DELETE journal 模式(也称 rollback journal 模式):
写入流程:
- 将原始页数据复制到 journal 文件(*.db-journal)
- 修改数据库主文件中的页
- 提交时删除 journal 文件
崩溃恢复:
- 如果 journal 文件存在,回滚主文件中的修改
这个模式有一个严重问题:写操作会阻塞所有读操作。在我们的系统中,DPR 线程每 200ms 写一次历史数据,同时 Reporter 线程每秒读一次状态数据,在高频写入场景下,读取延迟会显著增加。
4.2 WAL 模式的优势与风险
WAL(Write-Ahead Logging)模式的核心思想是:
写入流程:
- 将新的页内容追加写入 WAL 文件(*.db-wal)
- 主文件保持不变
- 定期 checkpoint:将 WAL 文件内容合并回主文件
并发优势:
- 读操作读主文件(快照读,不被写阻塞)
- 写操作只追加 WAL,不修改主文件
- 读写真正并发
对于我们的系统,WAL 模式的优势明显:
1 | ┌──────────────┬───────────────────┬─────────────────────────┐ |
嵌入式平台的 WAL 风险点:WAL 文件如果长期不做 checkpoint,会无限增长,在存储空间有限的嵌入式设备上是定时炸弹。建议配置:
1 | // 推荐在 DB 初始化时设置 |
在我们的代码 Init_CreadDataDB() 中,目前没有显式设置 journal_mode,这意味着使用的是默认的 DELETE 模式。结合单连接互斥锁的架构,这是一个合理的保守选择——互斥锁已经从应用层保证了串行访问,WAL
的并发优势在单连接模型下无从发挥。
结论:WAL 模式适合”多连接读写分离”架构;单连接互斥锁架构下,DELETE Journal 模式更简单可靠。
五、掉电安全写入:PRAGMA synchronous 的取舍
5.1 嵌入式平台的掉电威胁
储能系统的控制器有一个特殊场景:电池馈电异常导致控制器突然断电。这个场景在部署阶段的测试中会被反复模拟。如果数据库文件在写入中途断电,就会产生部分写(partial write),导致数据库损坏。
SQLite 的 PRAGMA synchronous 控制了数据落盘的严格程度:
1 | ┌────────┬─────┬───────────────────────────────────────────┬──────────────┐ |
在我们的系统中,对于配置数据(Data_SavedSettings),必须使用 FULL 级别——用户修改了充电电压上限后如果断电,下次重启仍然应该是用户设置的值,而不是出厂默认值。
对于历史数据(Data_HisStatusData、Data_BMSHealthData),NORMAL 级别通常足够——丢失最近几秒的历史曲线数据,不会造成安全事故,但会影响测试报告的完整性。
5.2 事务批量写入:性能与安全的平衡
在 data_processing.c 的 Refresh_HisParam() 中,我看到了这个系统最关键的性能优化模式:
1 | // 批量写入历史数据 |
不用事务的 SQLite 写入,每条 INSERT 都是一个隐式事务,意味着每次都要调用 fsync。在 eMMC 上,一次 fsync 大约耗时 5~50ms,100 条记录就是 0.5~5 秒。
用显式事务批量提交后,所有 INSERT 只需要一次 fsync,性能提升可达 100 倍以上。这是嵌入式 SQLite 工程实践中最重要的单项优化。
六、历史数据老化:循环索引 vs 时间窗口 DELETE
6.1 为什么不能无限累积?
一个运行中的储能系统,每 200ms 采集一次 100+ 个参数,一天的原始数据量是:
100参数 × 5次/秒 × 86400秒 = 43,200,000 条记录
即便做了变化量触发(只在值变化超过阈值时才记录),一天仍然会产生数十万条历史记录。在嵌入式设备 4GB~8GB 的存储空间里,不加控制的数据库会在数周内耗尽存储。
6.2 本系统的策略:循环索引(Circular Index)
在 data_processing.c 中,历史数据使用固定大小的循环覆盖:
1 | static UINT s_nCurrHisDataIdx = 1; |
结合 replace into 语句:
1 | replace into Data_HisStatusData values (?,?,?,?,?); |
replace into 等价于:如果主键(Idx)已存在则替换,不存在则插入。当 s_nCurrHisDataIdx 绕回到 1 时,新数据会覆盖最老的数据。这是一个零成本的 O(1) 老化策略——不需要 DELETE,不需要全表扫描,不产生碎片。
这个策略的参数是 MAX_HISDATA_RECORDS,合理设置这个值需要估算:
1 | // 假设 MAX_HISDATA_RECORDS = 86400 |
实际工程中需要根据存储空间、参数数量、记录频率调整这个值。
6.3 告警数据的类似处理
在 event_manager.c 中,告警记录也采用同样的循环索引策略。在系统启动时的 RestoreHisEvent() 函数中,会查询当前最大 Idx 以确定循环索引的起始位置,避免重启后覆盖还未上报的告警。
6.4 时间窗口 DELETE 策略的对比
另一种常见的老化策略是定期执行时间窗口清理:
1 | -- 每天凌晨 2 点清理 30 天前的数据 |
这个策略的优点是”保留最近 N 天”的语义更直观,但在嵌入式平台上有几个问题:
- DELETE 性能:大批量 DELETE 会产生大量页碎片,需要后续 VACUUM 才能释放磁盘空间,但 VACUUM 在 eMMC 上可能需要数分钟
- 时机敏感:凌晨的定时任务需要确保系统处于低负载状态,在储能系统中,凌晨可能正是充放电高峰
- 时间精度依赖:如果系统时钟异常(如 RTC 丢失),时间窗口判断会完全失效
综合对比,循环索引策略更适合嵌入式场景:确定性、低开销、不依赖时钟准确性。
七、CSV 与 SQLite 的互补:Save_CellInfo 的设计哲学
在 st_batt_testmode.c 中有一个有趣的设计:Save_CellInfo() 函数把电芯数据写到 CSV 文件而不是 SQLite:
1 |
|
为什么这里不用 SQLite?这是一个刻意的工程决策:
CSV 的优势场景:
- 电芯数据是纯时序数据,列结构固定(时间戳 + N 个电芯值)
- 测试工程师需要直接用 Excel 分析,CSV 是通用格式
- 每 10 秒一条,数据量不大,不需要复杂查询
- 测试结束后数据直接通过 SCP/SFTP 拷贝走,不需要在设备上长期存储
SQLite 的优势场景:
- 需要复杂查询(按时间范围、按状态过滤)
- 数据需要与其他表关联
- 需要事务保证(如告警数据必须与时间精确关联)
这个设计体现了”用合适的工具做合适的事”——CSV 和 SQLite 在同一个系统中共存,各司其职。
八、SQLite FTS 在日志查询中的应用
8.1 日志查询的痛点
系统运行过程中会产生大量文本日志(Data_AppLog、Data_DumpLog、Data_MBELog)。当现场工程师需要排查问题时,常见的需求是:
- “查找最近 24 小时内所有包含’FAULT’的日志”
- “找出 BMS_002 在昨天的所有告警记录”
- “搜索包含’COMM_FAIL’的最近 100 条记录”
如果日志存储为普通 TEXT 列,上述查询需要全表扫描 + LIKE 匹配:
SELECT * FROM Data_AppLog
WHERE Log_Content LIKE ‘%FAULT%’
AND Log_Time > 1700000000;
在数十万条记录的表上,这个查询可能需要数秒,对嵌入式平台来说不可接受。
8.2 FTS5 虚拟表方案
SQLite 的 FTS5(Full-Text Search 5)模块可以为文本内容建立倒排索引:
1 | // 在 Init_CreadDataDB() 中创建 FTS 虚拟表 |
查询变为:
1 | -- FTS 查询,毫秒级响应 |
snippet() 函数还能自动提取匹配词的上下文,非常适合日志展示。
8.3 FTS 在嵌入式的注意事项
FTS 索引需要额外的存储空间(通常是原始数据的 2~3 倍)。在嵌入式平台上,需要权衡:
- 对于高价值、需要频繁查询的日志(如告警日志、操作日志),值得建立 FTS 索引
- 对于高频低价值的诊断日志,建议用循环文件(logrotate)而不是 SQLite
九、MQTT 离线缓存:SQLite 作为消息队列的工程实践
9.1 断网场景
储能系统部署在户外或工业园区,网络连接并不总是稳定的。MQTT 报文发送失败后,系统需要将消息暂存,待网络恢复后重发。
SQLite 在这里充当了一个持久化消息队列:
1 | -- MQTT 离线消息缓存表(典型设计) |
Reporter 线程每次发送 MQTT 消息前先查询是否有缓存消息待发送:
1 | // 重发逻辑伪代码 |
9.2 缓存容量控制
离线消息缓存同样需要容量上限,防止长期断网导致数据库无限增长:
1 | -- 超过 10000 条时删除最老的记录 |
这等价于”只保留最新的 10000 条”,比时间窗口 DELETE 更健壮,不依赖时钟准确性。
十、AI 集成:SQLite 作为本地推理知识库
这是整篇文章最前沿的部分。我们正在探索的方向:将边缘 AI 推理结果存储到 SQLite,构建本地知识库。
10.1 场景:电池健康状态预测
我们的系统已经采集了大量 BMS 数据(电芯电压、温度、内阻)。如果在边缘侧部署一个轻量 ML 模型(如 ONNX Runtime Lite),可以实时推断电池的 SoH(State of Health,健康状态)趋势。
推理结果存储到 SQLite:
1 | -- AI 推理结果表 |
10.2 知识库的检索与利用
当新的 BMS 数据到来时,系统不仅运行实时推理,还会查询历史推理结果作为上下文:
1 | // 查询该电池过去 7 天的健康趋势 |
这个历史趋势数据可以作为特征输入给下一层的”趋势分析模型”,实现多时间尺度的预测——从当前快照推断即时状态,从历史趋势预测未来 30 天的退化曲线。
10.3 模型版本管理
AI 模型会随着训练数据积累而迭代更新。SQLite 可以同时管理多个模型版本的推理结果,便于对比分析:
1 | -- 对比不同版本模型的预测差异 |
这个查询帮助我们验证新模型是否在边缘端产生了系统性偏差,是模型上线前的重要验证步骤。
十一、工程实践总结:嵌入式 SQLite 的七条原则
经过这个项目的实战,我提炼出以下七条原则:
原则一:始终使用显式事务批量提交
单条 INSERT 就是隐式事务,每次 fsync 开销极高。批量数据必须用 BEGIN TRANSACTION / COMMIT 包裹。
原则二:单连接 + 互斥锁适合单机低并发场景
连接池的并发优势在嵌入式单核/双核场景下难以体现,而其带来的复杂度和内存开销是真实的。
原则三:循环索引优于定期 DELETE
REPLACE INTO + 循环递增主键,O(1) 老化,无碎片,不依赖时钟。适合固定容量的历史数据存储。
原则四:PRAGMA synchronous = FULL 用于配置数据
用户设置、校准参数等关键配置,必须用 FULL 级别保证掉电安全。历史数据可以用 NORMAL 级别换取性能。
原则五:WAL 模式配合多连接才有意义
单连接模型下,WAL 的并发优势无从发挥,但 WAL 文件增长风险依然存在。非必要不切 WAL。
原则六:CSV 和 SQLite 各司其职
纯时序、固定列、需要外部工具直接分析的数据用 CSV;需要查询、关联、事务保证的数据用 SQLite。
原则七:控制锁持有时间,避免长事务
Open_Lock_SqliteDB() 到 Close_UnLock_SqliteDB() 之间不做任何耗时操作(如网络 I/O、大量计算)。锁持有时间 > 10ms 就应该警觉,> 100ms 就是 Bug。
十二、结语
回顾这个系统中 SQLite 的使用,我最深的感受是:SQLite 的强大不在于功能,而在于它在各种约束下依然可靠。在 AM335x 这类 CPU 算力有限、存储空间紧张、电源不稳定的嵌入式环境里,SQLite
通过正确的配置和使用模式,扮演了配置存储、历史档案、事件记录、消息缓存四种角色,几乎满足了所有持久化需求。
而随着边缘 AI 的发展,SQLite 还将承担新的角色——本地推理知识库。它的轻量、自包含、零运维特性,让它成为边缘智能的天然存储基础设施。
工程实践的智慧不在于追求最新的技术,而在于在约束中找到最合适的平衡。SQLite 在嵌入式 Linux 中的工程实践,就是这种智慧的最好注解。
本文代码引用自储能管理系统(BESS)嵌入式控制器项目,核心文件:st_batt_testmode.c、dpr_loader.c、data_processing.c。