┌──────────────────────────────┐
        │ sqlite> .tables              │
        │ hist_202609  (1,248,302行)  │
        │ CREATE INDEX idx_time ...   │
        └──────────────────────────────┘
              embedded db · single file

上位机历史数据存哪查得快?SQLite 本地数据库选型:索引设计、百万行批量插入、分表归档与 SQL Server/LiteDB 对比

2026-09-28  //  纯技术  本地存储 · 数据工程 · C#
首页 / 行业资讯 / SQLite本地存储

一句话说清楚:上位机本地历史数据首选 SQLite——整个库就是一个文件、零部署、事务可靠,百万行级查询配合索引在毫秒级返回。但写法有讲究:批量写入必须包在一个事务里(快慢差上百倍),高频采集场景开 WAL 模式,数据量持续增长就按月分表并归档冷数据。用错地方(高并发多写入、多终端共享)它也会坑你。

SEC 01历史数据到底存哪?先看四种选择

上位机软件存历史数据,常见候选就四个,我按这些年项目里的实际取舍列清楚:

方案优点短板适用
SQLite单文件、免安装、事务可靠、体积小不适合多客户端高并发写入单机上位机首选
SQL Server Express/LocalDB功能全、T-SQL、可平滑升级要安装、占资源、部署重多站点联网、复杂查询
LiteDB.NET 原生文档库、无 SQL生态小、关联查询弱配置、对象缓存类数据
CSV/自定义文件简单直接、可 Excel 打开无索引、并发难、查不动导出报表,别做主存储

判断标准很实在:数据只在这台工控机上产生和查询,SQLite;多台机器的数据要汇总到一处,那是服务端数据库的活,别让单机库硬扛。

SEC 02表结构与连接初始化

建表 + 初始化(Microsoft.Data.Sqlite)
CREATE TABLE hist_202609 (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    ts        INTEGER NOT NULL,   -- 存Unix毫秒,查询排序都方便
    point_id  INTEGER NOT NULL,
    value     REAL    NOT NULL,
    quality   INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_202609_ts_point ON hist_202609(ts, point_id);
Db.cs — 推荐参数
var conn = new SqliteConnection(
    "Data Source=data/hist.db;Pooling=True;");
using (var c = conn.CreateCommand())
{
    c.CommandText =
      "PRAGMA journal_mode=WAL;" +   // 读写不互斥
      "PRAGMA synchronous=NORMAL;";    // 兼顾安全与速度
    c.ExecuteNonQuery();
}

两个设计细节:时间用整数毫秒而不是字符串,比较、索引、时区处理全都省事;点位编号单独成列并和时间组成联合索引,因为最常见的查询就是"某点位在某时间段的值"。

SEC 03索引怎么建,查询才能用上?

很多人建完表查询还是慢,多半是两个原因:没建对索引,或者写的 SQL 让索引用不上。

拿不准就看执行计划:在 SQL 前加 EXPLAIN QUERY PLAN,出现 USING INDEX 说明走了索引;看到 SCAN TABLE(全表扫描)就得回去查条件和索引。这个命令是 SQLite 调优最趁手的工具,每次写新查询都看一眼。

SEC 04批量插入为什么能差上百倍?

先理解 SQLite 的事务开销:默认情况下,每条 INSERT 都是一个自动事务,提交时要把数据刷盘并等磁盘确认。机械盘上一次刷盘约 10ms,一秒最多插一百来条——时间全耗在等磁盘,不是算力不够。

把一万条 INSERT 包进一个显式事务,只在最后提交时刷一次盘,写入直接提升两个数量级。再叠加两个优化:

CAUTION // 事务也别包太大一个事务塞几百万行,异常回滚代价高、中途断电损失大,还会长时间占连接。工程上按 5000~20000 行一批,循环提交,兼顾速度和安全。

SEC 05C# 批量写入完整代码

HistoryRepository.cs
public int BatchInsert(IReadOnlyList<Sample> samples)
{
    using var conn = new SqliteConnection(_cs);
    conn.Open();
    using var tran = conn.BeginTransaction();

    using var cmd = conn.CreateCommand();
    cmd.Transaction = tran;
    cmd.CommandText = "INSERT INTO hist_202609
        (ts,point_id,value,quality) VALUES($ts,$p,$v,$q)";
    var pTs = cmd.Parameters.Add("$ts", SqliteType.Integer);
    var pP  = cmd.Parameters.Add("$p",  SqliteType.Integer);
    var pV  = cmd.Parameters.Add("$v",  SqliteType.Real);
    var pQ  = cmd.Parameters.Add("$q",  SqliteType.Integer);
    cmd.Prepare();   // 只编译一次

    foreach (var s in samples)
    {
        pTs.Value = s.Ts; pP.Value = s.PointId;
        pV.Value = s.Value; pQ.Value = s.Quality;
        cmd.ExecuteNonQuery();
    }
    tran.Commit();
    return samples.Count;
}

高频采集时的推荐结构:采集线程把数据攒进内存队列,写入线程每秒(或攒够一批)取出调一次 BatchInsert。既保证批量,又把数据库操作和实时采集隔开。

SEC 06数据一直涨:按月分表与归档

单表几百万行后,索引变大、查询变慢、备份笨重。时序数据的经典做法是按月(或按周)分表,表名带月份(hist_202609、hist_202610),程序按时间路由到对应表;跨月查询就查两张表再合并。

NOTE // 分表键怎么选测点少、单点位数据量极大的场景(如高频波形),可以按 point_id 哈希分表;绝大多数 SCADA 历史库按时间分表最自然——因为查询和归档几乎都以时间为轴。

SEC 07WAL、并发写入与备份的坑

SEC 08实测数据与验证方法

在 i5、普通 SSD 上我做过一组对比,供你建立量级概念(实际随硬件波动):

写法(100 万行)耗时某时间段查询
逐条 INSERT,默认自动提交约 90 秒+—
单事务 + Prepare约 1.5 秒2~8 ms(有索引)
同上,但查询字段无索引—300~600 ms(全表扫)
  1. 灌百万行测试:先验证批量写入耗时和文件大小是否符合预期;
  2. EXPLAIN QUERY PLAN:对每类核心查询确认走索引;
  3. 断电演练:写入中直接杀进程、断电重启,库应能正常打开、数据回退到上一个完整事务——验证事务的可靠性承诺;
  4. 长跑观察:连续运行数周,配合分表归档,监控文件体积和查询耗时是否平稳。
CAUTION // 迁移老项目如果项目还在用 CSV 存历史数据,别一次性重写。先让新数据写 SQLite,保留旧文件只读;查询层按时间分流。双轨过渡一个月再清理,比"大爆炸"切换安全得多。

今天就可以动手:建一个单文件的 SQLite 库,把建表和联合索引写进去,灌十万条数据,然后分别用"无索引条件"和"有索引条件"查询对比一次。说实话,亲眼看到三百毫秒和三毫秒的差距,你对索引和事务的理解就从书本变成了肌肉记忆。SQLite 不花哨,但把它用对的上位机软件,数据这块基本不会再让你半夜接电话。

// tags: sqlite · embedded-database · index · bulk-insert · wal · partitioning · time-series · csharp