工业边缘的本地数据链路经常同时面对两类需求:一类是设备状态、告警、命令回执、配置变更等高频事务数据;另一类是采样、能耗、工况统计等需要聚合分析的历史数据。只选一个数据库,很容易把系统做成“要么写入慢,要么分析慢”。
本文按工程落地顺序梳理嵌入式数据库的选型边界、SQLite 事务路径、DuckDB 分析路径、Parquet 归档、混合架构、备份恢复和日常运维。文中代码偏教学演示,实际部署时还需要结合磁盘寿命、掉电语义、并发模型和升级策略统一设计。
一、先明确本地数据要解决什么问题
工业边缘使用嵌入式数据库,通常不是为了替代云端数据中心,而是为了解决四类现场问题:
- 断网续传:网络中断时先在本地落盘,恢复后按水位补传。
- 低延迟查询:就近提供设备最新状态、当日告警、局部报表。
- 本地分析:在现场完成窗口聚合、异常筛选和降采样,减少上行带宽。
- 审计与恢复:保留命令日志、配置版本、操作记录和校验信息。
先给每类数据定义清楚语义,再决定存储方式:
| 数据类型 | 典型要求 | 更适合的本地方案 |
|---|---|---|
| 设备最新状态 | 点查快、可覆盖更新 | SQLite / LMDB / key-value |
| 告警与命令回执 | 事务一致、可追溯 | SQLite |
| 高频采样缓存 | 批量写入、按时间保留 | SQLite / 时序库 / 追加文件 |
| 本地报表与聚合 | 扫描大范围数据、列式计算 | DuckDB |
| 长期归档 | 压缩率高、可离线搬运 | Parquet + Zstandard |
| 跨进程共享状态 | 并发读写语义清晰 | SQLite / LMDB |
有一个常见误区需要先修正:Embedded Kafka 不是嵌入式数据库。Kafka 类系统的定位是分布式日志与消息流,通常依赖独立进程、较多磁盘顺序写和较复杂的运维体系,并不适合作为网关内进程级本地数据库的默认选择。
二、SQLite 与 DuckDB 的定位差异
SQLite 和 DuckDB 都是嵌入式数据库,但它们优化的工作负载差异很大。
| 维度 | SQLite | DuckDB |
|---|---|---|
| 典型负载 | OLTP:事务、点查、小范围查询 | OLAP:扫描、聚合、分析 |
| 存储与执行 | 行式存储,B+Tree | 列式存储,向量化执行 |
| 写入模型 | 单写入者模型,WAL 支持读写并发 | 分析写入、批量导入能力强 |
| 查询强项 | 主键查找、短事务、状态更新 | 聚合、分组、join、窗口函数 |
| 常见角色 | 运行状态库、告警库、断网缓存 | 边缘报表库、批处理引擎 |
| 数据交换 | SQL、CSV、JSON | Parquet、CSV、Arrow、Pandas |
| 需要注意 | 长事务、无索引扫描、并发写竞争 | 大结果集内存、版本与文件兼容 |
简单说:SQLite 负责“频繁读写的业务账本”,DuckDB 负责“现场分析引擎”。让 SQLite 做全表统计,或让 DuckDB 承担高频随机小事务,都不是它的最优路径。
三、SQLite:本地事务与断网缓存
1. 建模与索引
时序缓存表不建议盲目使用自增 id 作为唯一查询入口。查询通常按设备和时间发生,索引应与查询路径一致。
import sqlite3
from contextlib import closing
from pathlib import Path
DB_PATH = Path("/var/lib/edge/edge.db")
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def connect(path: Path = DB_PATH) -> sqlite3.Connection:
conn = sqlite3.connect(
path,
timeout=5,
isolation_level="DEFERRED",
)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA foreign_keys = ON")
return conn
with closing(connect()) as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY,
device_id TEXT NOT NULL,
ts_ms INTEGER NOT NULL,
voltage REAL NOT NULL,
quality TEXT NOT NULL CHECK (quality IN ('good', 'bad', 'unknown')),
uploaded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device_time
ON telemetry(device_id, ts_ms DESC);
CREATE INDEX IF NOT EXISTS idx_telemetry_upload_time
ON telemetry(uploaded, id)
WHERE uploaded = 0;
"""
)
这里保留了 id 作为缓存队列的稳定行标识,uploaded 用于补传状态。部分索引只覆盖未上传数据,可以减少长时间运行后的索引维护成本。
2. 批量写入
SQLite 的事务粒度对写入性能影响很大。逐条提交会反复触发日志提交;把一批采样放进同一个事务,吞吐会显著提升。
def write_telemetry(rows: list[tuple[str, int, float, str]], now_ms: int):
with closing(connect()) as conn:
with conn: # commit on success, rollback on exception
conn.executemany(
"""
INSERT INTO telemetry
(device_id, ts_ms, voltage, quality, uploaded, created_at)
VALUES (?, ?, ?, ?, 0, ?)
""",
[(device_id, ts_ms, voltage, quality, now_ms)
for device_id, ts_ms, voltage, quality in rows],
)
如果业务要求“确认成功后必须掉电可恢复”,需要在事务提交语义上更谨慎:
journal_mode=WAL提供读写并发和崩溃恢复能力;synchronous=FULL提供更强的落盘确认,适合控制命令、计量和审计;synchronous=NORMAL在 WAL 模式下性能更好,但极端掉电时可能丢失最近已提交事务,需要业务确认这是否可接受;- 批量写入时还要限制事务大小,避免长事务阻塞清理和检查点。
3. PRAGMA 调优
def tune_sqlite(conn: sqlite3.Connection):
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = FULL")
conn.execute("PRAGMA wal_autocheckpoint = 1000")
conn.execute("PRAGMA cache_size = -65536") # 64 MiB
conn.execute("PRAGMA temp_store = MEMORY")
conn.execute("PRAGMA mmap_size = 268435456") # 256 MiB
这些参数不是越大越好。cache_size 和 temp_store 会增加内存压力;mmap_size 可改善读性能,但资源受限网关需要评估地址空间和故障隔离。对掉电敏感的数据,先确定 synchronous,再追求吞吐。
另外,journal_mode=WAL 是持久化到数据库文件的设置;busy_timeout、cache_size、mmap_size 等则按连接生效。连接池或多次重连场景下,不要只在一个连接里初始化一次就以为全局有效。
4. 补传队列
断网恢复后的补传要避免无限查询。按批次、主键水位和上传状态推进:
def fetch_upload_batch(batch_size: int = 500):
with closing(connect()) as conn:
rows = conn.execute(
"""
SELECT id, device_id, ts_ms, voltage, quality
FROM telemetry
WHERE uploaded = 0
ORDER BY id
LIMIT ?
""",
(batch_size,),
).fetchall()
return [dict(row) for row in rows]
def mark_uploaded(max_id: int):
with closing(connect()) as conn:
with conn:
conn.execute(
"UPDATE telemetry SET uploaded = 1 WHERE id <= ?",
(max_id,),
)
补传成功后再标记,比先标记再上传更安全。云端写入最好带有本地 id 或业务唯一键,用于幂等去重,避免重试造成重复入库。
5. 维护操作
def maintain_sqlite():
with closing(connect()) as conn:
integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
if integrity != "ok":
raise RuntimeError(f"SQLite integrity check failed: {integrity}")
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.execute("ANALYZE")
删除过期数据后,SQLite 不会自动把所有空闲页归还给操作系统。可以在低峰期执行 VACUUM,但 VACUUM 需要额外空间且可能耗时,网关上应限速、避开业务高峰,并确保掉电时有足够电量完成。
四、DuckDB:边缘分析与降采样
1. 建表与批量导入
DuckDB 适合把一批采样、CSV、Parquet 或 Arrow 表读入后执行分析。下面示例使用 Pandas 只作为数据结构演示,也可以换成 Arrow、Polars 或直接 INSERT。
import duckdb
ANALYTICS_DB = "/var/lib/edge/analytics.duckdb"
conn = duckdb.connect(ANALYTICS_DB)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS voltage_samples (
device_id VARCHAR NOT NULL,
ts TIMESTAMP NOT NULL,
voltage DOUBLE NOT NULL,
quality VARCHAR NOT NULL
)
"""
)
rows = [
("dev_001", "2026-09-11 10:00:00", 220.5, "good"),
("dev_002", "2026-09-11 10:00:00", 221.0, "good"),
]
conn.executemany(
"""
INSERT INTO voltage_samples(device_id, ts, voltage, quality)
VALUES (?, ?, ?, ?)
""",
rows,
)
如果来源已经是 DataFrame,也可以批量注册后写入:
import pandas as pd
df = pd.DataFrame(
{
"device_id": ["dev_001", "dev_002"],
"ts": pd.to_datetime(["2026-09-11 10:00:00"] * 2),
"voltage": [220.5, 221.0],
"quality": ["good", "good"],
}
)
conn.execute(
"""
INSERT INTO voltage_samples
SELECT device_id, ts, voltage, quality FROM df
"""
)
注意保持列名和列顺序显式声明,避免依赖 DataFrame 的偶然顺序。
2. 聚合与异常检测
result = conn.execute(
"""
SELECT
device_id,
date_trunc('minute', ts) AS minute,
count(*) AS sample_count,
avg(voltage) AS avg_voltage,
min(voltage) AS min_voltage,
max(voltage) AS max_voltage,
stddev_samp(voltage) AS std_voltage
FROM voltage_samples
WHERE ts >= current_timestamp - INTERVAL '1 hour'
AND quality = 'good'
GROUP BY device_id, date_trunc('minute', ts)
ORDER BY minute DESC, device_id
"""
).fetchall()
对边缘侧更稳妥的做法是:原始数据保留在 SQLite 或专用时序存储中,DuckDB 定期批量读取,输出分钟级、小时级降采样表,再由应用查询降采样结果。
3. Parquet 输入与输出
Parquet 适合作为长期归档和跨系统交换格式。DuckDB 可以直接查询文件,也可以把分析结果压缩导出:
query_result = duckdb.sql(
"""
SELECT device_id, ts, voltage
FROM read_parquet('/var/lib/edge/archive/**/*.parquet')
WHERE device_id = 'dev_001'
ORDER BY ts
"""
).fetchall()
conn.execute(
"""
COPY (
SELECT
device_id,
date_trunc('hour', ts) AS hour,
avg(voltage) AS avg_voltage,
max(voltage) AS max_voltage,
min(voltage) AS min_voltage,
count(*) AS sample_count
FROM voltage_samples
GROUP BY device_id, date_trunc('hour', ts)
)
TO '/var/lib/edge/export/voltage_hourly.parquet'
(
FORMAT PARQUET,
COMPRESSION ZSTD
)
"""
)
归档目录建议按 date=YYYY-MM-DD 或 device_group=... 分区,并记录 schema 版本、采集周期、压缩格式和生成程序版本,避免后续解析口径不一致。
4. 内存与并发
DuckDB 擅长批量分析,但边缘设备内存有限。需要显式设置:
conn = duckdb.connect(
ANALYTICS_DB,
config={
"memory_limit": "512MB",
"threads": 2,
"preserve_insertion_order": False,
},
)
几点实践建议:
- 避免在 1 GiB 内存的网关上执行无分区全量扫描;
- 查询先限定时间范围、设备组或分区列;
- 聚合结果落表,应用不重复扫描明细;
- 大任务分批执行,控制并发线程;
- 分析进程与实时采集进程隔离,避免相互影响。
DuckDB 的文件访问以单进程读写更省心,多进程同时修改同一个数据库文件需要特别确认版本语义和锁行为。更常见的边缘架构是“一个分析进程负责写入,其它进程读取导出的 Parquet 或降采样结果”。
五、SQLite + DuckDB 的组合架构
一个可落地的分层如下:
设备 / PLC / 逆变器
|
v
采集进程 --> SQLite 运行库
| 未上传队列 / 告警 / 命令回执
v
补传与状态查询
SQLite 或原始文件
|
| 定时分批导出
v
DuckDB 分析库
|
+--> 分钟 / 小时聚合表
+--> Parquet 长期归档
+--> 本地报表 API
这个组合的关键是批量搬运。不要每次页面刷新都用 DuckDB 扫描 SQLite 明细表,也不要让实时采集线程等待复杂分析完成。
直接读取 SQLite 的场景
DuckDB 提供 SQLite 扩展,可以用于一次性迁移或离线分析。离线部署时应确认扩展已预安装,不要假设现场一定可以访问外网:
import duckdb
conn = duckdb.connect()
conn.execute("LOAD sqlite")
conn.execute(
"ATTACH '/var/lib/edge/edge.db' AS edge_sqlite (TYPE SQLITE)"
)
result = conn.execute(
"""
SELECT device_id, count(*) AS n, avg(voltage) AS avg_voltage
FROM edge_sqlite.telemetry
WHERE quality = 'good'
GROUP BY device_id
ORDER BY n DESC
"""
).fetchall()
长时间运行的服务更适合把待分析数据导出为 Arrow、Parquet 或临时 CSV,再交给 DuckDB。这样边界清晰,也能避免分析查询长时间持有 SQLite 资源。
六、备份与恢复
SQLite 在线备份
不要在数据库运行时直接复制单个 .db 文件,尤其 WAL 模式下还有 -wal 和 -shm 文件。应使用 SQLite 的在线备份接口:
import sqlite3
from contextlib import closing
from pathlib import Path
def backup_sqlite(src_path: str, dst_path: str):
dst = Path(dst_path)
dst.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(src_path)) as src, \
closing(sqlite3.connect(dst_path)) as target:
src.backup(target)
备份完成后进行校验:
with closing(sqlite3.connect(dst_path)) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError("SQLite backup integrity check failed")
DuckDB 与归档备份
DuckDB 可以导出 Parquet 或 CSV。长期备份建议优先使用 Parquet:
import duckdb
from pathlib import Path
backup_path = Path("/backup/voltage_samples.parquet")
backup_path.parent.mkdir(parents=True, exist_ok=True)
conn = duckdb.connect(ANALYTICS_DB)
conn.execute(
f"""
COPY voltage_samples
TO '{backup_path}'
(FORMAT PARQUET, COMPRESSION ZSTD)
"""
)
conn.close()
恢复演练至少包括:
- 在空目录恢复备份;
- 校验行数、时间范围和设备清单;
- 抽样比对电压、状态码和聚合结果;
- 用应用程序实际版本启动;
- 记录恢复耗时和所需磁盘空间。
七、容量、保留与升级
嵌入式数据库必须有明确的保留策略,否则现场运行半年后最先失败的往往是磁盘。
建议把策略写成显式配置:
storage:
root: /var/lib/edge
high_watermark: 80%
stop_write_watermark: 92%
sqlite:
retention_days: 30
batch_delete_rows: 5000
vacuum_window: "02:00-04:00"
duckdb:
retention_days: 180
export_dir: /var/lib/edge/archive
parquet:
compression: zstd
partition_by: date
max_partition_files: 96
升级时还要处理兼容性:
- SQLite 文件格式长期稳定,但仍建议先备份再升级;
- DuckDB 版本演进较快,部署包应固定引擎版本;
- 跨版本归档优先使用 Parquet,而不是直接复制数据库文件;
- schema 变更采用版本表和向前兼容字段;
- 升级脚本必须能在低电量、磁盘写满和恢复中断时重复执行。
八、监控指标
嵌入式数据库应该是可观测的,而不是只看进程是否存活。
| 类别 | 指标 | 告警含义 |
|---|---|---|
| 容量 | 数据库大小、WAL 大小、归档目录大小 | 异常增长或清理失败 |
| 写入 | 插入速率、事务耗时、失败次数 | 采集链路或磁盘异常 |
| 队列 | 未上传行数、最老未上传时间 | 断网时间过长或补传停滞 |
| 查询 | 慢查询数、查询耗时分布 | 索引缺失或全表扫描 |
| 维护 | checkpoint、VACUUM、备份结果 | 运维任务失败 |
| 一致性 | integrity check、恢复演练结果 | 文件损坏或备份不可用 |
| 资源 | CPU、内存、磁盘 I/O、写入放大 | 分析任务影响实时链路 |
九、常见坑与应对
1. 把 SQLite 当成无限队列
问题:只写入不清理,WAL 和数据文件持续增长,最终写满磁盘。
应对:设置保留周期、高水位告警、分批删除和低峰期维护任务。
2. 只开 WAL,不定义持久语义
问题:默认照抄 synchronous=NORMAL,控制命令或计量数据在掉电后丢失。
应对:按数据价值分级。关键事务使用 FULL,普通采样可按 RPO 接受 NORMAL。
3. 用 DuckDB 承担高频小事务
问题:把 OLAP 数据库当成业务账本,随机小写入和并发修改都不是优势路径。
应对:SQLite 处理事务,DuckDB 处理批量分析。
4. 每次页面都全表聚合
问题:报表打开就扫描几个月明细,CPU、内存和响应时间一起恶化。
应对:预先生成分钟、小时、日级汇总表,并按分区裁剪。
5. 备份只复制数据库文件
问题:WAL 文件未一起处于一致状态,恢复时数据不完整。
应对:SQLite 使用在线备份 API,DuckDB 导出 Parquet 并做恢复校验。
6. 忽略存储介质寿命
问题:高频小写入导致 eMMC / SD 卡磨损,半年后网关批量故障。
应对:批量提交、降低写频率、使用工业级存储,并监控写入量与介质健康状态。
十、工程检查清单
上线前建议逐项确认:
- 每类数据的 RPO、保留周期和写满策略已定义;
- SQLite 关键事务的
journal_mode和synchronous已明确; - 数据库文件、WAL、备份和归档都有容量监控;
- 断网补传具备幂等键和水位;
- DuckDB 查询限定时间范围和分区;
- 分析任务与实时采集做了资源隔离;
- Parquet 归档记录了 schema 和版本;
- 备份可恢复,且恢复演练成功;
- schema 迁移脚本可重复执行;
- 掉电测试覆盖正常写入、批量提交和备份过程。
在 Zenova EdgeOS 的边缘数据链路中,SQLite 本地事务缓存与 DuckDB 分析任务的容量、写入延迟、队列水位和备份结果可以纳入统一的运行时观测体系,帮助现场提前发现磁盘写满、补传停滞和分析任务资源超限等问题。
TL;DR
- SQLite 适合本地 OLTP:状态、告警、命令回执、断网缓存和事务记录。
- DuckDB 适合本地 OLAP:批量扫描、聚合、异常分析和报表。
- SQLite WAL 提升并发,但掉电语义还要结合
synchronous与存储行为确认。 - DuckDB 的优势是列式分析,不适合当高频业务账本。
- Parquet 适合长期归档和跨系统交换,建议使用 Zstandard 压缩并分区存储。
- 备份必须用在线备份或显式导出,并定期做恢复演练。
- 嵌入式数据库的长期可用性取决于容量策略、介质寿命、schema 升级和监控。
下一步建议
- 按数据价值划分本地事务数据与分析数据;
- 为 SQLite 建立索引、批量写入和补传水位;
- 用 DuckDB 生成分钟级与小时级聚合表;
- 将明细归档为分区 Parquet;
- 补齐容量告警、备份校验和断电恢复演练。

330

被折叠的 条评论
为什么被折叠?



