一、从一次卡顿说起
前阵子帮朋友调一个智能门锁的问题,每次刷卡开锁都要等上一两秒才反应,有时候甚至感觉像死机了。朋友很着急,说硬件配置不差,就是存数据的时候慢。后来我打开代码一看,他每次写入一条日志就打开一次数据库连接,然后又立刻关闭,也没用事务包裹。这一下就明白了——不是硬件不行,是数据库被“频繁开关门”给拖垮了。
在嵌入式设备上,CPU、内存、Flash存储都有限,SQLite虽然轻量,但如果用不好,照样能让你怀疑人生。尤其是事务处理机制,用对了是加速器,用错了就是刹车片。这篇文章就从生活化的角度,把SQLite在嵌入式设备上的事务机制讲明白,顺便给你一堆能直接用的代码。
二、先把事务这东西拆开看
2.1 事务是什么
想象你去超市买东西,扫码、装袋、付款、拿小票,这一整套动作必须全部完成才算一次成功的购物。如果扫码到一半机器坏了,你肯定不希望东西被扣了钱但没拿到货。事务就是干这个的:把多个操作打包成一个整体,要么全部成功,要么全部回滚到最初状态。
在SQLite里,事务用BEGIN开始,COMMIT确认,ROLLBACK撤销。就这么简单。
2.2 事务的四大特性(通俗解释)
- 原子性:一锅端。要么全做,要么全不做。比如转账,转出和转入必须同时成功。
- 一致性:前后数据都对得上。比如库存不能变负数,账户余额不能凭空多出来。
- 隔离性:多个事务互不打扰。就像排队打饭,你打完我再打,不能混在一起。
- 持久性:事情一旦定了,就写在石头上了。设备突然断电,数据也不能丢。
这四个特性听起来很美好,但实现起来特别费劲,尤其是“持久性”和“隔离性”,直接影响性能。SQLite在这上面花了不少心思。
三、SQLite 在嵌入式设备上是怎么处理事务的
3.1 日志模式:回滚日志和预写日志
SQLite默认用“回滚日志”模式:修改数据前,先把原来的数据写到一个单独的日志文件里,等事务成功提交后再删掉日志。如果中途断电,SQLite能靠日志把数据恢复原样。
后来出了个“预写日志”模式,简称WAL。它不再直接改原数据库文件,而是把新改动追加到一个-wal文件里。等到合适的时机,再把WAL文件里的内容合并回主数据库。这种模式读写可以同时进行,性能好不少,对嵌入式设备尤其友好。
3.2 锁的机制:共享锁和排他锁
SQLite通过锁来控制并发。读操作要拿“共享锁”,多个读可以同时进行;写操作要拿“排他锁”,拿到手别人就不能读也不能写。想象一下图书馆:大家都可以同时看书(共享锁),但如果有人要在书上做标记(排他锁),其他人就得先放下书。
在嵌入式设备上,往往只有一个进程在用数据库,所以锁竞争不严重。但如果开了多线程,就要小心“数据库被锁住”的错误。
3.3 事务的三种状态
- 空闲:没有任何事务在跑。
- 活动:已经执行了
BEGIN,正在执行各种SQL。 - 提交中:执行了
COMMIT,正在写日志、刷磁盘,这个阶段最耗时。
搞清楚这些,咱们就能理解为什么有些操作慢得离谱了。
四、影响性能的几个关键点
4.1 磁盘写入频率
Flash存储有写入次数限制,而且写入速度远不如内存。每次提交事务,SQLite都可能把日志或数据刷到磁盘上。如果一秒钟提交100次,那就等于让Flash连续擦写100次,不慢才怪。
4.2 同步模式(synchronous)
SQLite有个PRAGMA synchronous设置,控制什么时候刷盘。
OFF:数据先放内存,不立刻写盘,速度最快,但掉电可能丢数据。NORMAL:某些关键点才刷盘,速度较快,相对安全。FULL:每次提交都刷盘,最安全,也最慢。
嵌入式设备上,如果数据不是特别重要,可以设成NORMAL,性能提升非常明显。
4.3 事务大小
事务里包含的操作越多,一次性提交的收益越大。但也不是越大越好,太大的事务会占用很多内存和日志空间,而且一旦失败,回滚成本也高。找到一个平衡点很重要。
4.4 内存缓存
SQLite会用一个缓存区域存放读过的数据页。缓存越大,命中率越高,去磁盘找数据的次数就越少。嵌入式设备内存有限,但可以适当调大cache_size。
五、用代码看性能差异
下面所有示例都用Python自带的sqlite3模块,简单直接,适合在嵌入式Linux设备上跑。先准备环境。
5.1 准备环境
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
import time
import os
# 建立一个测试数据库
db_path = "/tmp/test_perf.db"
# 如果文件已存在,先删掉,保证每次测试干净
if os.path.exists(db_path):
os.remove(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 创建一个简单的日志表
cursor.execute("""
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg TEXT NOT NULL,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 先关闭连接,后面用不同方式重新打开测试
conn.close()
print("环境准备好了")
5.2 不用事务批量插入
这是很多新手容易踩的坑:循环里每插入一条就自动提交一次。SQLite默认是自动提交模式,所以不用显式BEGIN也会每次COMMIT。
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
import time
db_path = "/tmp/test_perf.db"
# 重新打开连接
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 记录开始时间
start = time.time()
# 插入1000条日志,每次都不手动开事务
for i in range(1000):
cursor.execute(
"INSERT INTO logs (msg) VALUES (?)",
(f"log message {i}",)
)
# 没有显式BEGIN,每条语句后SQLite都会自动提交
# 这一步实际上要写一次磁盘
conn.close()
end = time.time()
print(f"不用事务插入1000条耗时: {end - start:.3f} 秒")
# 结果通常会比较慢,比如0.3秒以上,看具体设备
在性能弱的嵌入式设备上,这个耗时可能放大到好几秒。
5.3 用事务批量插入
把1000条插入打包成一个事务,提交一次。
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
import time
db_path = "/tmp/test_perf.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
start = time.time()
# 开启事务
cursor.execute("BEGIN")
for i in range(1000):
cursor.execute(
"INSERT INTO logs (msg) VALUES (?)",
(f"log message {i}",)
)
# 一次性提交
cursor.execute("COMMIT")
conn.close()
end = time.time()
print(f"用事务插入1000条耗时: {end - start:.3f} 秒")
# 通常比上面快10倍以上
看到差距了吧?一次提交和一千次提交,性能天差地别。
5.4 对比结果
我曾在树莓派Zero上做过类似测试,不用事务大约要1.2秒,用事务只要0.05秒,快了二十多倍。这就是事务的魅力。
5.5 设置适当的同步模式
事务虽然快,但如果把FULL同步模式开着,批量提交依然要等磁盘刷完。我们可以针对不那么重要的数据,降低同步级别。
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
import time
db_path = "/tmp/test_perf.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 把同步模式设为NORMAL,速度与安全性折中
cursor.execute("PRAGMA synchronous = NORMAL")
start = time.time()
cursor.execute("BEGIN")
for i in range(1000):
cursor.execute(
"INSERT INTO logs (msg) VALUES (?)",
(f"log message {i}",)
)
cursor.execute("COMMIT")
conn.close()
end = time.time()
print(f"NORMAL模式下事务插入1000条耗时: {end - start:.3f} 秒")
# 通常比FULL模式再快30%-50%
如果业务允许极端情况丢一点数据,还可以试试PRAGMA synchronous = OFF,那速度会更快,但我不推荐在关键数据上这么干。
六、在真实设备上的优化建议
6.1 合理使用事务
写多行数据时,用显式事务包裹。千万不要在循环里逐条提交。可以把批量数据攒到一定数量,比如500条或者1000条,再统一提交。
6.2 避免长事务
事务时间越长,持有锁的时间越久,别的事务越容易阻塞。嵌入式设备上如果同时有读操作,长事务会让读操作等很久。所以尽量保持每个事务在几十毫秒内完成。
6.3 使用WAL模式
WAL模式允许读写并发,还能降低磁盘写入频率。在嵌入式设备上非常有用。开启方法很简单:
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
conn = sqlite3.connect("/tmp/test_perf.db")
cursor = conn.cursor()
# 开启WAL模式
cursor.execute("PRAGMA journal_mode = WAL")
# 确认一下结果
mode = cursor.execute("PRAGMA journal_mode").fetchone()[0]
print(f"当前日志模式: {mode}")
# WAL模式下,写事务提交时,只是追加到-wal文件,速度更快
conn.close()
注意,WAL模式会产生额外的-wal和-shm文件,嵌入式文件系统要支持。大部分Linux设备没问题。
6.4 调整page_size和cache_size
把页大小调大,可以减少I/O次数。一般设4096或8192。缓存如果够用,也可以调大:
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
conn = sqlite3.connect("/tmp/test_perf.db")
cursor = conn.cursor()
# 页大小设置为8192字节,需要在建表前设置,所以一般放在初始化时
cursor.execute("PRAGMA page_size = 8192")
# 缓存设为10000页,约80MB?不,8192*10000=80MB,嵌入式设备可能太多
# 实际按设备内存来,比如2000页约16MB
cursor.execute("PRAGMA cache_size = 2000")
conn.close()
print("page_size 和 cache_size 已调整")
注意,page_size必须和VACUUM配合才能对已有数据库生效,最好在创建数据库之前就设置好。
七、应用场景与优缺点分析
7.1 适合的场景
- 智能家居设备:门锁、传感器、温控器,数据量不大,但需要持久化。
- 工业仪表:记录运行状态、报警信息,偶尔批量写入。
- 边缘网关:从多个传感器采集数据,聚合成事务写入。
- 医疗便携设备:存储病人基本信息和测量记录,要求可靠。
7.2 不适合的场景
- 高并发写入:多个进程同时频繁写同一个SQLite库,锁竞争会让人崩溃。此时该用PostgreSQL或MySQL。
- 超大数据库:几十GB以上的数据,SQLite管理起来比较吃力。
- 需要分布式事务:多设备数据同步,SQLite不支持网络协议。
7.3 优点
- 零配置:不用启动服务,文件即数据库。
- 轻量级:库文件只有几MB,适合嵌入式。
- 可靠性强:事务机制成熟,支持崩溃恢复。
- 跨平台:几乎所有操作系统都能跑。
7.4 缺点
- 写入吞吐量有限:单库串行写入,性能上限不高。
- 对Flash不够友好:如果频繁提交,会加快Flash磨损。
- 并发能力弱:读写锁机制导致多进程场景不理想。
八、注意事项
第一,不要在每次操作后都关闭数据库连接。嵌入式设备上打开连接开销很大,尽量复用同一个连接。
第二,注意busy_timeout设置。当遇到锁冲突时,SQLite默认立刻报错。可以设置等待时间:
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
conn = sqlite3.connect("/tmp/test_perf.db", timeout=5) # 等待5秒
cursor = conn.cursor()
# 也可以在连接后设置
cursor.execute("PRAGMA busy_timeout = 3000") # 3秒
conn.close()
print("busy_timeout 设置完成")
第三,备份数据库时要额外小心。不应该直接复制正在写的大文件,建议使用sqlite3 .backup命令或在线备份API。
第四,写事务时尽量预绑定参数,不要拼接SQL字符串。这不仅防止注入,还能提高性能,因为SQLite可以重用执行计划。
# 技术栈:Python 3 + 内置 sqlite3 模块
import sqlite3
import time
conn = sqlite3.connect("/tmp/test_perf.db")
cursor = conn.cursor()
# 预编译插入语句,重复执行更快
cursor.execute("BEGIN")
stmt = cursor.executemany(
"INSERT INTO logs (msg) VALUES (?)",
[(f"msg {i}",) for i in range(100)]
)
cursor.execute("COMMIT")
conn.close()
print("executemany 批量插入完成")
executemany配合事务,比单独循环还要快一些,推荐使用。
第五,如果设备掉电频繁,请务必保持FULL同步模式,否则可能丢失最近几次事务。安全与性能要权衡。
九、总结
SQLite在嵌入式设备上的性能,很大程度上取决于你怎么使用事务。事务的本质是把多次磁盘写入合并成一次,减少Flash擦写次数,同时保证数据一致性。用对了,速度提升十倍以上;用不对,就会遇到门锁卡顿那种尴尬。
关键点无非这几个:批量操作务必使用事务;根据数据重要程度调同步模式;尽量采用WAL模式;不要频繁开关连接;合理设置缓存和页大小。把这些记在心里,你的嵌入式设备数据库就不会拖后腿。
事务机制不是洪水猛兽,它就是一把好用的工具。理解它的脾气,顺着它的节奏来,你会发现SQLite其实非常贴心。希望这篇文章能帮你在实际项目中少踩一些坑,让设备跑得又稳又顺。
评论
围绕“SQLite事务处理机制在嵌入式设备中的性能表现”参与讨论