C#实时曲线绘制与SQL数据库存储实战:从Chart到GDI+的避坑指南

发布时间:2026/10/2 14:03:07
C#实时曲线绘制与SQL数据库存储实战:从Chart到GDI+的避坑指南 简介这份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 双缓冲自己维护一个ListPointF存数据在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 批量写入别一条一条插最慢的写法是每个点开一次连接、执行一次INSERT5Hz 跑一天就是 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只加不删或者ListPointF无限增长。解决给数据窗口设上限超过就删最旧的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 刷新用最新值写库按批次走各管各的节奏。最后说个我自己的习惯任何曲线项目我都会先写一个“假数据源”把绘图和写库跑通确认界面流畅、数据能落库、异常能兜底再接真实设备。这样出问题时能快速判断是采集层还是展示层的问题省下大量现场排查时间。曲线这东西画出来容易画得稳、存得住、查得快才是真功夫。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询