去年接手一个光伏电站监控系统改造项目时,我遇到了与这个案例几乎相同的困境。系统需要实时采集逆变器的电压、电流、功率等参数,每秒产生约1000条记录。最初的方案采用内存队列缓冲数据,结果某次机房断电导致2小时数据丢失,运维团队不得不手动补录数据,场面极其狼狈。
工业场景的数据采集与普通应用有着本质区别:
我们曾测试过多种方案,实测数据如下表:
| 方案 | 吞吐量(条/秒) | 断电可靠性 | 依赖项 | 内存占用 |
|---|---|---|---|---|
| 内存队列 | 50,000+ | 完全丢失 | 无 | 高 |
| 直接写入SQL Server | 800-1,200 | 可靠 | 数据库服务 | 中 |
| Redis持久化 | 15,000-20,000 | 可选 | Redis服务 | 高 |
| SQLite WAL模式 | 8,000-12,000 | 可靠 | 单文件 | 低 |
在工业上位机场景中,SQLite展现出三大不可替代的优势:
嵌入式架构:单文件数据库无需服务部署,特别适合Windows工控机环境。我曾见过某汽车厂产线因数据库服务崩溃导致全线停产,而SQLite完全规避这类风险。
ACID事务保障:通过预写日志(WAL)机制,即使程序崩溃也能保证数据完整性。实测中我们模拟了突然断电场景,SQLite恢复后数据零丢失。
性能可优化:通过以下技巧,我们在i5-8250U工控机上实现了12,000条/秒的稳定写入:
csharp复制public class PersistentQueue<T> : IDisposable
{
private readonly BlockingCollection<T> _writeBuffer;
private readonly BlockingCollection<List<T>> _persistBuffer;
private readonly Thread _persistThread;
private readonly SQLiteConnection _connection;
public PersistentQueue(string dbPath)
{
// 初始化双缓冲队列
_writeBuffer = new BlockingCollection<T>(capacity: 10_000);
_persistBuffer = new BlockingCollection<List<T>>(capacity: 5);
// 初始化SQLite连接
var csb = new SQLiteConnectionStringBuilder {
DataSource = dbPath,
JournalMode = SQLiteJournalModeEnum.Wal,
SyncMode = SynchronizationModes.Normal
};
_connection = new SQLiteConnection(csb.ToString());
InitializeDatabase();
// 启动持久化线程
_persistThread = new Thread(PersistWorker) {
IsBackground = true,
Priority = ThreadPriority.BelowNormal
};
_persistThread.Start();
}
}
这个设计的关键点在于:
我们在数据库中添加了元数据表来管理状态:
sql复制CREATE TABLE IF NOT EXISTS _metadata (
key TEXT PRIMARY KEY,
value TEXT
);
-- 记录最后成功写入的批次ID
INSERT OR IGNORE INTO _metadata VALUES ('last_batch_id', '0');
每次持久化完成后更新批次ID,程序重启时从最后记录的ID继续处理。
通过以下PRAGMA设置,我们获得了30%的性能提升:
csharp复制// 在连接打开后立即执行
using var cmd = _connection.CreateCommand();
cmd.CommandText = @"
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -10000; -- 10MB缓存
PRAGMA temp_store = MEMORY;
PRAGMA page_size = 4096;
PRAGMA auto_vacuum = INCREMENTAL;";
cmd.ExecuteNonQuery();
重要提示:
synchronous=NORMAL在Windows上可能造成数据损坏风险,建议配合UPS使用。若对可靠性要求极高,应设为FULL。
对比测试显示,批量提交能带来数量级的性能提升:
| 批量大小 | 吞吐量(条/秒) | CPU占用率 |
|---|---|---|
| 单条提交 | 1,200 | 45% |
| 100条 | 8,500 | 32% |
| 500条 | 12,000 | 28% |
| 1000条 | 11,800 | 30% |
最佳实践是动态调整批量大小,我们在代码中实现了自适应算法:
csharp复制private void PersistWorker()
{
var batch = new List<T>(InitialBatchSize);
var sw = new Stopwatch();
while (!_persistBuffer.IsCompleted)
{
sw.Restart();
// 动态调整批次大小
var optimalSize = Math.Max(
InitialBatchSize,
(int)(_writeBuffer.Count * 0.3));
batch.Clear();
for (int i = 0; i < optimalSize; i++)
{
if (_writeBuffer.TryTake(out var item))
batch.Add(item);
else
break;
}
if (batch.Count > 0)
BulkInsert(batch);
// 根据处理时间调整下次批次
var processTime = sw.ElapsedMilliseconds;
if (processTime > 50)
InitialBatchSize = Math.Max(100, InitialBatchSize - 50);
else if (processTime < 20)
InitialBatchSize = Math.Min(5000, InitialBatchSize + 100);
}
}
在某次连续运行30天的测试中,WAL文件增长到32GB。解决方案是:
csharp复制// 每天凌晨执行维护
Task.Run(async () =>
{
while (true)
{
await Task.Delay(TimeSpan.FromHours(24));
using var cmd = _connection.CreateCommand();
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
cmd.ExecuteNonQuery();
}
});
当多个采集点共用一个磁盘时,出现了写入延迟。我们通过以下方法解决:
在早期版本中,遇到过数据库锁导致的死锁。关键改进点:
busy_timeout参数csharp复制private void BulkInsert(List<T> items)
{
int retryCount = 0;
while (retryCount < MaxRetries)
{
try
{
using var transaction = _connection.BeginTransaction();
// 批量插入操作...
transaction.Commit();
return;
}
catch (SQLiteException ex) when (ex.ResultCode == SQLiteErrorCode.Busy)
{
retryCount++;
Thread.Sleep((int)Math.Pow(2, retryCount) * 10);
}
}
throw new TimeoutException($"写入超时,重试{MaxRetries}次失败");
}
sql复制CREATE TABLE sensor_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
timestamp DATETIME NOT NULL,
voltage REAL NOT NULL,
current REAL NOT NULL,
power REAL NOT NULL,
status INTEGER NOT NULL
);
CREATE INDEX idx_sensor_data_device ON sensor_data (device_id);
CREATE INDEX idx_sensor_data_time ON sensor_data (timestamp);
csharp复制private void BulkInsert(List<SensorData> items)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO sensor_data
(batch_id, device_id, timestamp, voltage, current, power, status)
VALUES (@b, @d, @t, @v, @c, @p, @s)";
cmd.Parameters.Add("@b", DbType.Int64);
// 其他参数...
using var transaction = _connection.BeginTransaction();
foreach (var item in items)
{
cmd.Parameters["@b"].Value = _currentBatchId;
// 设置其他参数...
cmd.ExecuteNonQuery();
}
transaction.Commit();
// 更新元数据
UpdateMetadata("last_batch_id", _currentBatchId.ToString());
_currentBatchId++;
}
建议实现以下监控指标:
_writeBuffer.Count.wal文件我们在实际项目中实现了简单的看板:
csharp复制public class QueueMonitor
{
public int PendingItems => _writeBuffer.Count;
public TimeSpan ProcessingLag { get; private set; }
public long WalFileSize => GetWalFileSize();
public void Start()
{
_timer = new Timer(_ => {
ProcessingLag = DateTime.Now - _lastPersistTime;
OnMetricsUpdated?.Invoke(this);
}, null, 1000, 1000);
}
}
这套方案在三个光伏电站稳定运行超过18个月,经历了多次意外断电考验,始终保持数据零丢失。对于中小型数据采集场景(<5MB/s),SQLite持久化队列提供了绝佳的可靠性与性能平衡。