编辑
2026-04-17
C#
00

目录

先说说这个问题有多烦
先看效果
🏗️ 架构思路:三件事必须分开
⚡ AlarmService:告警的完整生命周期
触发:async 是底线
确认:幂等设计别省
解除:COALESCE 的妙用
查询:通用方法复用
📝 OperationLogger:日志不是事后补救
消灭魔法字符串
强类型重载:让调用方少思考
批量写入:事务是性能的关键
🖥️ UI层:跨线程安全不能靠运气
🎯 三句话总结

先说说这个问题有多烦

做工业软件这行,你迟早会遇到这种场景——

凌晨两点,某条产线的设备突然报警。操作员盯着屏幕,界面卡了三秒才刷新。日志窗口里一片空白。没人知道这个告警是什么时候触发的,也没人知道上一个操作是谁做的、做了什么。

这不是极端案例。这是我亲眼见过的真实现场。

问题的根源,往往不是硬件,不是网络,而是软件架构从一开始就没想清楚。告警逻辑、UI刷新、数据库写入全部塞在同一个线程里,互相阻塞。日志记录散落在各个按钮事件里,格式五花八门,查起来像在考古。

今天这篇文章,就聊聊我在一个基于C# WinForms的工业SCADA项目里,怎么把AlarmServiceOperationLogger这两个核心模块从头设计清楚的。


先看效果

image.png

image.png

image.png

image.png

🏗️ 架构思路:三件事必须分开

在动手写代码之前,我强迫自己先想清楚三个问题:

谁负责触发告警?谁负责存储?谁负责展示?

这三件事,必须物理隔离。

很多项目死在这里——在按钮点击事件里直接写数据库,在数据库回调里直接刷新UI控件。看起来省事,实际上是在给自己挖坑。设备轮询线程一旦触发告警,UI线程被阻塞,整个界面冻住,操作员什么也干不了。

我的方案是这样的:

设备轮询线程 └── AlarmService.TriggerAlarmAsync() ├── 异步写入 SQLite(不阻塞) └── 触发事件 OnAlarmTriggered └── AlarmPanel 订阅(BeginInvoke 跨线程安全刷新)

三层完全解耦。轮询线程只管触发,数据库只管持久化,UI只管展示。任何一层出问题,不会拖垮其他层。


⚡ AlarmService:告警的完整生命周期

一条告警从触发到关闭,经历这几个阶段:触发 → 确认 → 解除

很多项目只实现了触发,确认和解除的逻辑一塌糊涂。我见过用删除记录来"解除告警"的,审计的时候什么历史都查不到,直接被客户投诉。

触发:async 是底线

csharp
using 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显示了告警,但数据库还没写进去,这时候程序崩了,这条告警就永远消失了。工业现场,这种数据丢失是要出事故报告的。

确认:幂等设计别省

确认告警这个操作,操作员可能手抖点两次。如果不做幂等处理,要么报错,要么把确认时间覆盖掉。

csharp
const 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,静默忽略,不报错,不覆盖。简单,但很多人漏掉。

解除:COALESCE 的妙用

解除告警比确认复杂一点——有些告警可能直接被解除,跳过了"确认"步骤。这时候确认信息也得补上,不能留空。

csharp
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";

COALESCE 的意思是:如果confirmed_at已经有值,就保留原值;如果是NULL,才用新值填进去。一行SQL解决了条件判断,比在C#里写if判断再拼SQL要优雅得多。

查询:通用方法复用

历史查询和未确认查询,字段映射逻辑高度重复。我抽了一个QueryAlarmsAsync私有方法,用fullMapping参数控制是否映射确认/解除字段:

csharp
private 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:日志不是事后补救

很多团队对操作日志的态度是——"先把功能做完,日志以后再加"。

以后,就是永远不加。

我的做法是把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); }

调用方只需要:

csharp
await _logger.LogSetParamAsync("DEV-001", "温度上限", 85, 90, true, "操作员A");

日志里自动记录了改了什么参数、从多少改到多少。审计的时候直接看JSON,一目了然。

批量写入:事务是性能的关键

高频采集场景下,每条日志单独开一个数据库连接,性能会很差。批量写入用事务包裹:

csharp
public 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倍以上,这不是夸张,是我实测的数据。


🖥️ UI层:跨线程安全不能靠运气

AlarmPanel订阅OnAlarmTriggered事件,这个事件由设备轮询线程触发。直接操作UI控件会抛InvalidOperationException,这是WinForms的线程模型决定的。

处理方式固定的,记住就好:

csharp
private 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 许可协议。转载请注明出处!