☰
C#实时曲线绘制与SQL数据库存储实战:从Chart到GDI+的避坑指南
2026/10/2 14:03:05 网站建设 项目流程

简介:这份C#绘制曲线示例源码面向具备一定C#基础、希望掌握数据可视化与图形编程的开发者,重点解决如何将SQL数据库中的数据读取后动态绘制成曲线的问题。资源包共25个文件,约50KB,以cs源代码、csproj项目文件、sln解决方案文件、sql数据库脚本及resx资源文件为主,另含少量exe、dll与pdb等编译产物,结构紧凑,便于在Visual Studio中直接打开运行。源码完整演示了通过ADO.NET连接数据库、执行查询、解析数据点,再借助GDI+或WPF绘制曲线的流程,并附有建库脚本,方便读者还原数据环境。目前已有748人学习下载,适合作为课程设计、图表控件开发或数据可视化入门的参考案例,读者可从中理解数据驱动绘图的核心思路,并在此基础上扩展异步加载、动态刷新与鼠标交互等优化功能。

1. 从一张实时曲线图说起:C# 绘制曲线并落库到底难在哪

很多做上位机的朋友第一次接到“把采集到的数据画成曲线,同时存进 SQL 数据库”这个需求时,直觉反应是:画图用 Chart 控件,存数据用 ADO.NET,两件事拼一起不就完了。真动手才发现,曲线刷新和数据写入如果都塞在 UI 线程里,界面几秒就卡成幻灯片;采集频率一高,数据库连接池被瞬间打满;程序跑一晚上,内存涨到几个 G,曲线越画越慢。标题里的“C# 绘制曲线示例源码(含 SQL 数据库)”看着像个入门 demo,实际落地时它同时踩中了实时绘图、数据持久化、线程调度三个坑区。这篇笔记就按我做过的一套方案,把曲线怎么画、数据怎么存、参数怎么调、哪里最容易翻车讲清楚,适合正在做 C# 上位机、数据监控、工控采集这类项目的同学照着复现。

2. 曲线绘制方案选型:Chart、GDI+ 还是第三方库

2.1 三种主流画法的适用边界

C# 里画曲线,常见做法无非三类。第一类是 WinForms 自带的System.Windows.Forms.DataVisualization.Charting,也就是大家说的 Chart 控件;第二类是直接用 GDI+ 在Panel或PictureBox上Graphics.DrawCurve;第三类是引入第三方图表库,比如 OxyPlot、LiveCharts、ScottPlot 这类。

选型不是看哪个“高级”,而是看你的数据量和刷新节奏。我一般按下面这张表来定:

方案适合数据量刷新频率上手成本典型问题
Chart 控件单条曲线 < 5000 点< 10 Hz低点数一多重绘明显卡顿
GDI+ 手绘单条 < 20000 点20~60 Hz中坐标换算、双缓冲要自己处理
OxyPlot / ScottPlot单条 10 万点以上30 Hz+中高需要额外引包,样式定制要读文档

如果你的项目是实验室数据采集、每秒几个点,Chart 控件完全够用,别过度设计。但如果是工控现场、振动信号、电机电流这种每秒几百上千点的场景,Chart 控件会拖垮整个界面,这时候要么上 GDI+ 自己控制重绘,要么直接换 ScottPlot 这类为高频刷新优化的库。

2.2 用 Chart 控件画一条实时曲线的最小代码

先给一个能直接跑起来的最小示例,后面再逐步加数据库和线程优化。新建一个 WinForms 项目,拖一个Chart控件命名为chart1,再拖一个Timer命名为timer1。

// 初始化:只在窗体加载时执行一次 private void Form1_Load(object sender, EventArgs e) { // 清掉设计器默认生成的示例序列,避免重复 chart1.Series.Clear(); // 新建一个折线序列 var series = new Series("实时值"); series.ChartType = SeriesChartType.Line; // 折线 series.BorderWidth = 2; // 线宽 series.XValueType = ChartValueType.DateTime; // X 轴按时间显示 chart1.Series.Add(series); // X 轴按时间格式显示,避免挤成一团 chart1.ChartAreas[0].AxisX.LabelStyle.Format = "HH:mm:ss"; chart1.ChartAreas[0].AxisX.IntervalType = DateTimeIntervalType.Seconds; chart1.ChartAreas[0].AxisX.Interval = 5; timer1.Interval = 200; // 200ms 一个点,即 5Hz timer1.Start(); } private void timer1_Tick(object sender, EventArgs e) { var series = chart1.Series["实时值"]; // 追加一个新点:X 是当前时间,Y 是模拟采集值 series.Points.AddXY(DateTime.Now, GetSensorValue()); // 只保留最近 300 个点,防止无限增长拖慢重绘 if (series.Points.Count > 300) series.Points.RemoveAt(0); // 让 X 轴跟随最新数据滚动 chart1.ChartAreas[0].AxisX.Minimum = series.Points[0].XValue.ToOADate(); chart1.ChartAreas[0].AxisX.Maximum = series.Points[series.Points.Count - 1].XValue.ToOADate(); }

这段代码的逻辑说明:Series是 Chart 控件里的一条曲线,ChartType决定画折线还是柱状;Points.AddXY每次追加一个数据点;关键在RemoveAt(0)这一步,如果不删旧点,点数会无限累积,Chart 控件每次重绘都要遍历全部点,跑十几分钟界面就卡死。AxisX.Minimum/Maximum用ToOADate()转换,是因为 Chart 内部时间轴用的是 OADate 双精度值,直接赋DateTime会报类型错误,这是新手最常踩的一个坑。

参数上,timer1.Interval决定采样节奏,200ms 对应 5Hz,适合大多数传感器展示;BorderWidth是线宽,超过 3 在高分屏上会显得糊;保留点数 300 是我常用的经验值,对应 200ms 采样约一分钟的窗口,你可以按“窗口时长 ÷ 采样间隔”来算。

2.3 高频场景为什么必须换 GDI+ 或第三方库

Chart 控件每次AddXY都会触发一次内部重绘,而且它的重绘是整块区域重画,不是增量更新。当采样频率到 50Hz 以上,或者曲线超过几千点,UI 线程会被重绘占满,鼠标拖动窗口都会卡。这时候常见做法是改用 GDI+ 双缓冲:自己维护一个List<PointF>存数据,在OnPaint里用Graphics.DrawCurve一次性画出来,配合BufferedGraphics消除闪烁。再进一步就是换 ScottPlot,它内部用位图缓存和降采样,十万点也能流畅刷新。选型时先问自己两个问题:每秒多少点、要保留多长时间窗口,答案基本就决定了用哪套。

3. 把曲线数据写进 SQL 数据库:表结构、批量插入与连接管理

3.1 表结构怎么设计才不后悔

曲线数据本质是“时间 + 数值”的时序数据,表结构设计直接决定后面查询和清理的效率。我一般建两张表:一张存采集会话,一张存数据点。

-- 采集会话表:一次采集任务一行 CREATE TABLE CurveSession ( SessionId INT IDENTITY(1,1) PRIMARY KEY, SessionName NVARCHAR(100) NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NULL ); -- 数据点表:每个采样点一行 CREATE TABLE CurvePoint ( PointId BIGINT IDENTITY(1,1) PRIMARY KEY, SessionId INT NOT NULL, SampleTime DATETIME NOT NULL, Value FLOAT NOT NULL, CONSTRAINT FK_CurvePoint_Session FOREIGN KEY (SessionId) REFERENCES CurveSession(SessionId) ); -- 按会话和时间建索引,查询某段曲线时不用全表扫 CREATE INDEX IX_CurvePoint_Session_Time ON CurvePoint (SessionId, SampleTime);

逻辑说明:SessionId把一次采集的所有点归到一起,方便按任务查询和删除;SampleTime用DATETIME而不是字符串,才能做范围查询和排序;Value用FLOAT对应 C# 的double,如果精度要求高可以换DECIMAL。索引IX_CurvePoint_Session_Time是关键,没有它,当表里几百万行时,按时间段查曲线会慢到怀疑人生。注意PointId用BIGINT,长时间采集单表很容易过亿行,INT会溢出。

3.2 用参数化 SQL 批量写入,别一条一条插

最慢的写法是每个点开一次连接、执行一次INSERT,5Hz 跑一天就是 43 万次往返,数据库直接被你打哭。正确做法是攒一批再批量插,用参数化语句防注入也防类型转换错误。

// 攒够 500 个点或超过 2 秒就批量写一次 private readonly List<(DateTime time, double value)> _buffer = new(); private readonly object _bufferLock = new(); private DateTime _lastFlush = DateTime.Now; private void BufferPoint(DateTime time, double value) { lock (_bufferLock) { _buffer.Add((time, value)); // 两个触发条件:数量够或时间到 if (_buffer.Count >= 500 || (DateTime.Now - _lastFlush).TotalSeconds >= 2) { FlushBuffer(); } } } private void FlushBuffer() { if (_buffer.Count == 0) return; // 复制一份再清空,缩短锁持有时间 var batch = new List<(DateTime, double)>(_buffer); _buffer.Clear(); _lastFlush = DateTime.Now; const string sql = @" INSERT INTO CurvePoint (SessionId, SampleTime, Value) VALUES (@sid, @t, @v)"; using var conn = new SqlConnection(_connString); conn.Open(); using var tran = conn.BeginTransaction(); using var cmd = new SqlCommand(sql, conn, tran); // 参数只声明一次,循环里改值 var pSid = cmd.Parameters.Add("@sid", SqlDbType.Int); var pTime = cmd.Parameters.Add("@t", SqlDbType.DateTime); var pValue = cmd.Parameters.Add("@v", SqlDbType.Float); foreach (var (time, value) in batch) { pSid.Value = _currentSessionId; pTime.Value = time; pValue.Value = value; cmd.ExecuteNonQuery(); } tran.Commit(); }

逻辑说明:_buffer在内存里攒点,达到 500 个或距上次写入超过 2 秒就触发FlushBuffer。用lock保护是因为采集线程和定时刷新线程可能同时访问。写入时开一个事务,500 条一起提交,比自动提交快一个数量级。参数对象在循环外声明、循环内只改Value,避免每次ExecuteNonQuery都重新解析 SQL。

参数上,批量大小 500 是折中值:太小写入频繁,太大内存占用高且一次失败丢的数据多。2 秒的兜底是为了低频采集时不让数据在内存里待太久。_connString建议放配置文件,别硬编码。如果你的数据库是 SQL Server,还可以换成SqlBulkCopy,几万点一秒内能写完,但要注意它不走事务默认行为,需要自己包SqlTransaction。

3.3 连接管理和异常兜底

数据库连接是稀缺资源,SqlConnection实现了IDisposable,用using保证释放。连接字符串里建议开连接池(默认就开),但别把Max Pool Size设太大,几十就够了,设成几百反而会因为连接争用变慢。写入失败时不能直接抛异常让程序崩,我一般加一层重试:捕获SqlException,等 500ms 重试一次,连续失败三次就把这批数据落到本地临时文件,等数据库恢复再补写。这个兜底逻辑看着多余,但现场断网、数据库重启是常事,没有它你丢的就是一整段曲线。

4. 避坑与排查:曲线和数据库联调时最容易翻车的五件事

4.1 界面卡死,曲线不动

现象:程序跑起来后界面无响应,曲线停在某一点不再更新。原因:采集或数据库写入跑在 UI 线程上,timer1_Tick里做了耗时操作,消息循环被阻塞。解决:把采集和写库放到后台线程,用Task.Run或独立线程,UI 线程只负责拿数据刷新控件。跨线程更新控件要用Invoke或BeginInvoke,直接赋值会抛跨线程异常。

4.2 数据库连接池耗尽

现象:运行一段时间后报“超时时间已到,但是尚未从池中获取连接”。原因:每次写入都new SqlConnection却没释放,或者异常路径下Dispose没执行。解决:所有连接用using包裹,确保异常时也能释放;检查是否有循环里开连接没关的代码;把Max Pool Size调到合理值并观察sp_who2里的连接数。

4.3 曲线越画越慢,内存持续上涨

现象:任务管理器里内存曲线一路向上,曲线刷新肉眼可见变慢。原因:Series.Points只加不删,或者List<PointF>无限增长。解决:给数据窗口设上限,超过就删最旧的;GDI+ 方案里用固定长度的环形缓冲区,避免List.RemoveAt(0)这种 O(n) 操作。

4.4 时间轴显示错乱或报类型异常

现象:X 轴要么不显示,要么抛“输入字符串的格式不正确”。原因:Chart 的时间轴内部用 OADate,直接赋DateTime或格式字符串不匹配都会出问题。解决:统一用ToOADate()转换,LabelStyle.Format用标准时间格式串如HH:mm:ss,别自己拼。

4.5 批量写入丢数据

现象:数据库里的点数比实际采集的少。原因:程序退出时缓冲区里还有没写的数据,或者批量提交失败后没重试。解决:窗体关闭事件里强制FlushBuffer;写入失败加重试和本地落盘兜底;批量大小别设太大,降低单次失败的影响面。

5. 进阶技巧:用降采样和分区表让长周期曲线依然能打

曲线画得顺、数据存得下,只是及格线。真正拉开差距的是长周期运行时的表现。我做过一个项目要连续采集一个月,每秒 100 点,算下来单表 2.6 亿行,直接查一个月曲线数据库要跑几十秒。后来做了两件事:降采样和分区。

降采样是在查询层做的。画曲线时屏幕就一两千像素宽,你查十万个点出来画,多出来的点根本显示不出来,纯属浪费。常见做法是按时间分桶取平均或取最大最小值:

-- 按 10 秒一桶取平均,把 100Hz 数据降到 0.1Hz 用于长周期展示 SELECT DATEADD(SECOND, DATEDIFF(SECOND, '2000-01-01', SampleTime) / 10 * 10, '2000-01-01') AS BucketTime, AVG(Value) AS AvgValue FROM CurvePoint WHERE SessionId = @sid AND SampleTime BETWEEN @start AND @end GROUP BY DATEDIFF(SECOND, '2000-01-01', SampleTime) / 10 ORDER BY BucketTime;

逻辑说明:DATEDIFF算出每个点相对基准时间的秒数,除以桶宽再乘回去,就把同一桶的点归到一起,AVG取平均。桶宽 10 秒是示例,实际按你要展示的时间跨度调:看一天用 1 分钟桶,看一个月用 10 分钟桶。这样查询返回的行数从百万级降到几千,前端画起来毫无压力。

分区是数据库层的优化。SQL Server 支持按SampleTime做分区,把每个月的数据放到不同文件组,查询时只扫相关分区,删除旧数据直接TRUNCATE分区,比DELETE快几个数量级。建分区需要先建分区函数和分区方案,步骤稍多,但一次配好长期受益。如果用的是 SQL Server Express 版本,分区功能受限,那就退而求其次,按月建归档表,程序里按时间路由写入。

还有一个容易忽略的点:曲线控件的刷新和数据库写入要解耦。我见过有人图省事,在写库成功回调里才更新曲线,结果数据库一慢,曲线跟着卡。正确做法是采集线程拿到数据后,一份丢给 UI 刷新,一份丢给写库队列,两者互不等待。UI 刷新用最新值,写库按批次走,各管各的节奏。

最后说个我自己的习惯:任何曲线项目,我都会先写一个“假数据源”把绘图和写库跑通,确认界面流畅、数据能落库、异常能兜底,再接真实设备。这样出问题时能快速判断是采集层还是展示层的问题,省下大量现场排查时间。曲线这东西,画出来容易,画得稳、存得住、查得快才是真功夫。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询