做工业软件这行,你迟早会遇到这种场景——
凌晨两点,某条产线的设备突然报警。操作员盯着屏幕,界面卡了三秒才刷新。日志窗口里一片空白。没人知道这个告警是什么时候触发的,也没人知道上一个操作是谁做的、做了什么。
这不是极端案例。这是我亲眼见过的真实现场。
问题的根源,往往不是硬件,不是网络,而是软件架构从一开始就没想清楚。告警逻辑、UI刷新、数据库写入全部塞在同一个线程里,互相阻塞。日志记录散落在各个按钮事件里,格式五花八门,查起来像在考古。
今天这篇文章,就聊聊我在一个基于C# WinForms的工业SCADA项目里,怎么把AlarmService和OperationLogger这两个核心模块从头设计清楚的。




在动手写代码之前,我强迫自己先想清楚三个问题:
谁负责触发告警?谁负责存储?谁负责展示?
这三件事,必须物理隔离。
很多项目死在这里——在按钮点击事件里直接写数据库,在数据库回调里直接刷新UI控件。看起来省事,实际上是在给自己挖坑。设备轮询线程一旦触发告警,UI线程被阻塞,整个界面冻住,操作员什么也干不了。
我的方案是这样的:
设备轮询线程 └── AlarmService.TriggerAlarmAsync() ├── 异步写入 SQLite(不阻塞) └── 触发事件 OnAlarmTriggered └── AlarmPanel 订阅(BeginInvoke 跨线程安全刷新)
三层完全解耦。轮询线程只管触发,数据库只管持久化,UI只管展示。任何一层出问题,不会拖垮其他层。
一条告警从触发到关闭,经历这几个阶段:触发 → 确认 → 解除。
很多项目只实现了触发,确认和解除的逻辑一塌糊涂。我见过用删除记录来"解除告警"的,审计的时候什么历史都查不到,直接被客户投诉。
csharpusing System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.Threading.Tasks;
namespace AppScada
{
public class AlarmRecord
{
public long Id { get; set; }
public string DeviceCode { get; set; }
public string AlarmCode { get; set; }
public int AlarmLevel { get; set; }
public string AlarmMessage { get; set; }
public DateTime TriggeredAt { get; set; }
public DateTime? ConfirmedAt { get; set; }
public string ConfirmedBy { get; set; }
public bool IsResolved { get; set; }
public string AlarmLevelText => AlarmLevel switch
{
1 => "提示",
2 => "警告",
3 => "故障",
_ => "未知"
};
}
public class AlarmService
{
private readonly string _connStr;
private readonly Lazy<Task> _dbInitTask;
public event Action<AlarmRecord> OnAlarmTriggered;
public event Action<long> OnAlarmConfirmed;
public event Action<long> OnAlarmResolved;
public AlarmService(string dbPath)
{
_connStr = $"Data Source={dbPath};Version=3;";
_dbInitTask = new Lazy<Task>(InitializeDatabaseAsync);
}
public async Task TriggerAlarmAsync(
string deviceCode,
string alarmCode,
int level,
string message)
{
var record = new AlarmRecord
{
DeviceCode = deviceCode,
AlarmCode = alarmCode,
AlarmLevel = level,
AlarmMessage = message,
TriggeredAt = DateTime.Now,
IsResolved = false
};
record.Id = await InsertAlarmAsync(record);
OnAlarmTriggered?.Invoke(record);
}
public async Task ConfirmAlarmAsync(long alarmId, string confirmedBy)
{
const string sql = @"
UPDATE alarm_record
SET confirmed_at = @confirmedAt,
confirmed_by = @confirmedBy
WHERE id = @id
AND confirmed_at IS NULL";
await ExecuteNonQueryAsync(sql, cmd =>
{
cmd.Parameters.AddWithValue("@confirmedAt",
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
cmd.Parameters.AddWithValue("@confirmedBy", confirmedBy ?? string.Empty);
cmd.Parameters.AddWithValue("@id", alarmId);
});
OnAlarmConfirmed?.Invoke(alarmId);
}
public async Task ResolveAlarmAsync(long alarmId, string operatedBy)
{
const string sql = @"
UPDATE alarm_record
SET is_resolved = 1,
confirmed_at = COALESCE(confirmed_at,
@confirmedAt),
confirmed_by = COALESCE(confirmed_by,
@confirmedBy)
WHERE id = @id
AND is_resolved = 0";
await ExecuteNonQueryAsync(sql, cmd =>
{
cmd.Parameters.AddWithValue("@confirmedAt",
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
cmd.Parameters.AddWithValue("@confirmedBy", operatedBy ?? string.Empty);
cmd.Parameters.AddWithValue("@id", alarmId);
});
OnAlarmResolved?.Invoke(alarmId);
}
public async Task<List<AlarmRecord>> GetUnconfirmedAlarmsAsync()
{
const string sql = @"
SELECT id, device_code, alarm_code, alarm_level,
alarm_message, triggered_at
FROM alarm_record
WHERE confirmed_at IS NULL
AND is_resolved = 0
ORDER BY alarm_level DESC, triggered_at DESC";
return await QueryAlarmsAsync(sql, null);
}
public async Task<List<AlarmRecord>> GetHistoryAlarmsAsync(
string deviceCode = null,
int? level = null,
DateTime? from = null,
DateTime? to = null,
int pageSize = 500,
int pageIndex = 0)
{
var sql = @"
SELECT id, device_code, alarm_code, alarm_level,
alarm_message, triggered_at,
confirmed_at, confirmed_by, is_resolved
FROM alarm_record
WHERE 1 = 1";
if (!string.IsNullOrEmpty(deviceCode))
sql += " AND device_code LIKE @deviceCode";
if (level.HasValue)
sql += " AND alarm_level = @level";
if (from.HasValue)
sql += " AND triggered_at >= @from";
if (to.HasValue)
sql += " AND triggered_at <= @to";
sql += " ORDER BY triggered_at DESC LIMIT @limit OFFSET @offset";
return await QueryAlarmsAsync(sql, cmd =>
{
if (!string.IsNullOrEmpty(deviceCode))
cmd.Parameters.AddWithValue("@deviceCode", $"%{deviceCode}%");
if (level.HasValue)
cmd.Parameters.AddWithValue("@level", level.Value);
if (from.HasValue)
cmd.Parameters.AddWithValue("@from",
from.Value.ToString("yyyy-MM-dd HH:mm:ss"));
if (to.HasValue)
cmd.Parameters.AddWithValue("@to",
to.Value.ToString("yyyy-MM-dd HH:mm:ss"));
cmd.Parameters.AddWithValue("@limit", pageSize);
cmd.Parameters.AddWithValue("@offset", pageIndex * pageSize);
}, fullMapping: true);
}
public async Task<AlarmRecord> GetAlarmByIdAsync(long alarmId)
{
const string sql = @"
SELECT id, device_code, alarm_code, alarm_level,
alarm_message, triggered_at,
confirmed_at, confirmed_by, is_resolved
FROM alarm_record
WHERE id = @id";
var list = await QueryAlarmsAsync(sql, cmd =>
{
cmd.Parameters.AddWithValue("@id", alarmId);
}, fullMapping: true);
return list.Count > 0 ? list[0] : null;
}
public async Task<Dictionary<int, int>> GetUnresolvedCountByLevelAsync()
{
await EnsureDatabaseInitializedAsync();
const string sql = @"
SELECT alarm_level, COUNT(*) AS cnt
FROM alarm_record
WHERE is_resolved = 0
GROUP BY alarm_level";
var result = new Dictionary<int, int>
{
{ 1, 0 }, { 2, 0 }, { 3, 0 }
};
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var cmd = new SQLiteCommand(sql, conn);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var lvl = reader.GetInt32(0);
var cnt = reader.GetInt32(1);
result[lvl] = cnt;
}
return result;
}
private async Task<long> InsertAlarmAsync(AlarmRecord record)
{
await EnsureDatabaseInitializedAsync();
const string sql = @"
INSERT INTO alarm_record
(device_code, alarm_code, alarm_level,
alarm_message, triggered_at, is_resolved)
VALUES
(@deviceCode, @alarmCode, @alarmLevel,
@alarmMessage, @triggeredAt, 0);
SELECT last_insert_rowid();";
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var cmd = new SQLiteCommand(sql, conn);
cmd.Parameters.AddWithValue("@deviceCode", record.DeviceCode);
cmd.Parameters.AddWithValue("@alarmCode", record.AlarmCode);
cmd.Parameters.AddWithValue("@alarmLevel", record.AlarmLevel);
cmd.Parameters.AddWithValue("@alarmMessage", record.AlarmMessage ?? string.Empty);
cmd.Parameters.AddWithValue("@triggeredAt",
record.TriggeredAt.ToString("yyyy-MM-dd HH:mm:ss"));
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt64(result);
}
private async Task<List<AlarmRecord>> QueryAlarmsAsync(
string sql,
Action<SQLiteCommand> paramSetup,
bool fullMapping = false)
{
await EnsureDatabaseInitializedAsync();
var list = new List<AlarmRecord>();
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var cmd = new SQLiteCommand(sql, conn);
paramSetup?.Invoke(cmd);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var rec = new AlarmRecord
{
Id = reader.GetInt64(0),
DeviceCode = reader.GetString(1),
AlarmCode = reader.GetString(2),
AlarmLevel = reader.GetInt32(3),
AlarmMessage = reader.IsDBNull(4) ? string.Empty : reader.GetString(4),
TriggeredAt = DateTime.Parse(reader.GetString(5))
};
if (fullMapping && reader.FieldCount > 6)
{
rec.ConfirmedAt = reader.IsDBNull(6)
? null
: DateTime.Parse(reader.GetString(6));
rec.ConfirmedBy = reader.IsDBNull(7)
? string.Empty
: reader.GetString(7);
rec.IsResolved = reader.GetInt32(8) == 1;
}
list.Add(rec);
}
return list;
}
private async Task ExecuteNonQueryAsync(
string sql,
Action<SQLiteCommand> paramSetup)
{
await EnsureDatabaseInitializedAsync();
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var cmd = new SQLiteCommand(sql, conn);
paramSetup?.Invoke(cmd);
await cmd.ExecuteNonQueryAsync();
}
private Task EnsureDatabaseInitializedAsync()
{
return _dbInitTask.Value;
}
private async Task InitializeDatabaseAsync()
{
const string sql = @"
CREATE TABLE IF NOT EXISTS alarm_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_code TEXT NOT NULL,
alarm_code TEXT NOT NULL,
alarm_level INTEGER NOT NULL,
alarm_message TEXT,
triggered_at TEXT NOT NULL,
confirmed_at TEXT,
confirmed_by TEXT,
is_resolved INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alarm_record_triggered_at
ON alarm_record(triggered_at);
CREATE INDEX IF NOT EXISTS idx_alarm_record_resolved
ON alarm_record(is_resolved);";
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var cmd = new SQLiteCommand(sql, conn);
await cmd.ExecuteNonQueryAsync();
}
}
}
顺序很重要。先写库,再通知UI。 反过来的话,UI显示了告警,但数据库还没写进去,这时候程序崩了,这条告警就永远消失了。工业现场,这种数据丢失是要出事故报告的。
确认告警这个操作,操作员可能手抖点两次。如果不做幂等处理,要么报错,要么把确认时间覆盖掉。
csharpconst string sql = @"
UPDATE alarm_record
SET confirmed_at = @confirmedAt,
confirmed_by = @confirmedBy
WHERE id = @id
AND confirmed_at IS NULL"; -- 关键:只更新未确认的
AND confirmed_at IS NULL 这一行,就是幂等的全部秘密。已经确认过的,再点一次,SQL影响行数为0,静默忽略,不报错,不覆盖。简单,但很多人漏掉。
解除告警比确认复杂一点——有些告警可能直接被解除,跳过了"确认"步骤。这时候确认信息也得补上,不能留空。
csharpconst string sql = @"
UPDATE alarm_record
SET is_resolved = 1,
confirmed_at = COALESCE(confirmed_at, @confirmedAt),
confirmed_by = COALESCE(confirmed_by, @confirmedBy)
WHERE id = @id
AND is_resolved = 0";
COALESCE 的意思是:如果confirmed_at已经有值,就保留原值;如果是NULL,才用新值填进去。一行SQL解决了条件判断,比在C#里写if判断再拼SQL要优雅得多。
历史查询和未确认查询,字段映射逻辑高度重复。我抽了一个QueryAlarmsAsync私有方法,用fullMapping参数控制是否映射确认/解除字段:
csharpprivate async Task<List<AlarmRecord>> QueryAlarmsAsync(
string sql,
Action<SQLiteCommand> paramSetup,
bool fullMapping = false)
{
// ...
while (await reader.ReadAsync())
{
var rec = new AlarmRecord { /* 基础字段 */ };
if (fullMapping && reader.FieldCount > 6)
{
rec.ConfirmedAt = reader.IsDBNull(6) ? null
: DateTime.Parse(reader.GetString(6));
rec.IsResolved = reader.GetInt32(8) == 1;
}
list.Add(rec);
}
}
这个模式在工业项目里特别实用。查询场景多,但底层逻辑高度相似,抽出来复用比复制粘贴强太多。
很多团队对操作日志的态度是——"先把功能做完,日志以后再加"。
以后,就是永远不加。
我的做法是把OperationLogger设计成一个强类型的服务,调用方不需要关心SQL怎么写,只需要告诉它"我做了什么"。
操作类型如果用字符串字面量散落在各处,迟早出问题:
csharp// ❌ 这样写,哪天手滑打错了,查日志查不到
await logger.LogAsync(device, "set_param", detail, true);
// ✅ 统一常量类,IDE有提示,重构有保障
await logger.LogAsync(device, ActionTypes.SetParam, detail, true);
ActionTypes 这个静态类,就十几行代码,但能省掉很多不必要的排查时间。
通用的LogAsync接受object detail,灵活但容易乱。我为高频操作提供了强类型重载:
csharp// 记录参数修改,自动带上新旧值对比
public Task LogSetParamAsync(
string deviceCode,
string paramName,
object oldValue,
object newValue,
bool success,
string @operator = "")
{
return LogAsync(deviceCode, ActionTypes.SetParam, new
{
param = paramName,
oldValue = oldValue,
newValue = newValue
}, success, @operator);
}
调用方只需要:
csharpawait _logger.LogSetParamAsync("DEV-001", "温度上限", 85, 90, true, "操作员A");
日志里自动记录了改了什么参数、从多少改到多少。审计的时候直接看JSON,一目了然。
高频采集场景下,每条日志单独开一个数据库连接,性能会很差。批量写入用事务包裹:
csharppublic async Task LogBatchAsync(IEnumerable<(...) > entries)
{
using var conn = new SQLiteConnection(_connStr);
await conn.OpenAsync();
using var transaction = conn.BeginTransaction();
using var cmd = new SQLiteCommand(sql, conn, transaction);
// 预定义参数,循环中只改Value,减少GC压力
var pDevice = cmd.Parameters.Add("@deviceCode", DbType.String);
// ...
foreach (var entry in entries)
{
pDevice.Value = entry.DeviceCode;
// ...
await cmd.ExecuteNonQueryAsync();
}
transaction.Commit();
}
这里有个细节——参数对象在循环外创建,循环内只更新Value。看起来是小优化,但在高频写入场景下,减少对象分配对GC压力的缓解是实打实的。SQLite单事务批量写入,比逐条写入快10倍以上,这不是夸张,是我实测的数据。
AlarmPanel订阅OnAlarmTriggered事件,这个事件由设备轮询线程触发。直接操作UI控件会抛InvalidOperationException,这是WinForms的线程模型决定的。
处理方式固定的,记住就好:
csharpprivate void OnAlarmTriggered(AlarmRecord record)
{
if (InvokeRequired)
{
BeginInvoke(new Action<AlarmRecord>(OnAlarmTriggered), record);
return;
}
// 到这里,一定在UI线程
_alarmList.Insert(0, record);
System.Media.SystemSounds.Exclamation.Play();
}
用BeginInvoke而不是Invoke,是因为Invoke是同步的——轮询线程会等UI线程处理完才继续。BeginInvoke是异步投递,轮询线程扔完消息就走,不等结果,吞吐量更高。
告警服务的核心:先持久化再通知,幂等设计防重复,事件驱动解耦UI。
日志服务的核心:强类型消灭魔法字符串,事务批量写入保性能,JSON序列化留审计空间。
跨线程的核心:BeginInvoke异步投递,轮询线程不等UI,界面永远不卡死。
工业软件的稳定性要求,比普通业务系统高一个量级。一个告警丢失、一条日志缺失,在生产现场可能意味着事故调查时找不到证据,意味着合规审计时无法交差。
把这两个模块做扎实,是整个SCADA系统的地基。地基不稳,上面建得再漂亮也白搭。
#C#开发 #工业软件 #WinForms #SQLite #SCADA
相关信息
通过网盘分享的文件:AppScada.zip 链接: https://pan.baidu.com/s/1_xVmjfFc4FuWWD1UVptJoQ?pwd=jy7m 提取码: jy7m --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!