趋势曲线卡顿,90% 的情况不是控件不行,是你把屏幕上根本显示不下的点,全塞给它画了。一个 1200 像素宽的图表,画 100 万个点和画 2000 个点,肉眼看不出区别,但前者每次缩放重绘都在做 500 倍无用功。这个化工园区能源上位机项目,历史表攒了 1800 万条温度压力数据,查一天曲线要 12 秒、拖动缩放像放幻灯片。四刀优化下来,加载 300 毫秒、缩放跟手。全过程记录如下。

先量化,别凭感觉调:卡顿时的三个基线数据

调性能最忌讳"我觉得是数据库慢"。我们先在现场工控机(i5-7代、8G 内存、机械硬盘)上量了三个数:

12.4 s查一天曲线(约 17 万点)从点查到画面出来
0.5 FPS鼠标框选缩放时的重绘帧率,拖影严重
680 MB曲线窗体打开后的进程内存峰值

用 Stopwatch 分段一卡,时间分布很清楚:SQL 查询 3.1 秒、DataTable 填充 4.8 秒、Chart 控件加点和重绘 4.5 秒。数据库和绘图各打五十大板,内存则是 17 万个 DataPoint 对象连同一个大 DataTable 全堆在内存里。方向明确了:少查、少传、少画。

瓶颈本质:图表宽度 1200 像素,意味着同一时刻最多只有 1200 个"列"能画出不同的 x 位置,17 万个点里平均 140 个点挤在同一个像素列上,互相覆盖。这 140 个点里只有最大值和最小值会影响这一列的轮廓,其余 138 个画了也白画。这就是后面所有优化的出发点。

第一刀:视口查询,只查屏幕要显示的时间段

第 1 刀数据库层:按时间范围 + 像素数取数,别全表搬查询 3.1s → 0.2s

原来的写法是打开窗体就把整月数据查进 DataTable,再由控件"自己显示"。改成视口驱动:当前 X 轴可视范围是什么时间段,就只查这个时间段;历史表按月分表 + SampleTime 聚簇索引,时间段查询直接走索引 Seek。更进一步,在 SQL 里按像素列做分段取最值,17 万行进库、2000 多列出库:

-- 把可视时间段按像素列数(约2000列)分桶,每桶取min/max,轮廓一点不丢
SELECT Bucket, MIN(SampleTime) AS T, MIN(RawValue) AS VMin, MAX(RawValue) AS VMax
FROM (
    SELECT RawValue, SampleTime,
        ABS(DATEDIFF(SECOND, @t0, SampleTime)) / @bucketSec AS Bucket
    FROM dbo.TagHistory_202609
    WHERE TagKey = @tag AND SampleTime BETWEEN @t0 AND @t1
) x
GROUP BY Bucket
ORDER BY Bucket;
  • 每桶输出 min 和 max 两个值(包络线画法),尖峰毛刺不会被平均掉——这对温度报警追溯特别重要,平均值抽稀会把超限尖峰抹掉
  • 缩放/平移时拿新的可视时间段重新查,查的永远是"屏幕那一屏"的数据量

第二刀:应用层降采样,LTTB 保趋势形状

第 2 刀数据层:LTTB 降采样,点数砍到像素量级17万点 → 2000点

有些场景数据不走 SQL(比如断网缓存的本地文件、或实时内存序列),降采样就得在 C# 侧做。别用"每隔 N 个取一个"的均匀抽样——那会把曲线的峰值和拐点抽没。用 LTTB(Largest-Triangle-Three-Buckets)算法:把点分成若干桶,每桶选一个和前后桶构成三角形面积最大的点,视觉上曲线形状几乎不变,点数压缩几十倍。我们封了一个通用方法,目标点数直接按图表像素宽度 ×2 给:

// LTTB 降采样:保留趋势轮廓,threshold = 目标点数
public static List<PointF> Lttb(List<PointF> data, int threshold)
{
    if (data.Count <= threshold) return data;
    var result = new List<PointF> { data[0] };
    float bucketSize = (float)(data.Count - 2) / (threshold - 2);
    int a = 0;
    for (int i = 0; i < threshold - 2; i++)
    {
        int rangeStart = (int)((i + 1) * bucketSize) + 1;
        var avgX = 0f; var avgY = 0f; int cnt = 0;
        for (int j = rangeStart; j < rangeStart + bucketSize && j < data.Count; j++)
        { avgX += data[j].X; avgY += data[j].Y; cnt++; }
        avgX /= cnt; avgY /= cnt;   // 下一桶的平均点

        int bucketStart = (int)(i * bucketSize) + 1;
        float maxArea = -1; int maxIdx = bucketStart;
        for (int j = bucketStart; j < rangeStart && j < data.Count; j++)
        {   // 三角形面积:前一选中点 a、候选点 j、下一桶均值点
            float area = Math.Abs((data[a].X - avgX) * (data[j].Y - data[a].Y)
                                 - (data[a].X - data[j].X) * (avgY - data[a].Y)) / 2f;
            if (area > maxArea) { maxArea = area; maxIdx = j; }
        }
        result.Add(data[maxIdx]); a = maxIdx;
    }
    result.Add(data[data.Count - 1]);
    return result;
}

▲ 报警追溯类曲线用 min/max 包络(第一刀的 SQL),工艺趋势类用 LTTB,按场景选。

第三刀:别再一个点一个点 Add,自定义控件一笔画线

第 3 刀绘图层:双缓冲 + DrawLines 一笔成图重绘 4.5s → 40ms

Chart 控件(System.Windows.Forms.DataVisualization)功能全但偏重,上万点后每点都是一个 DataPoint 对象,内部布局和命中检测全在跑。我们对只需要看趋势、不需要 tooltip 逐点命中的场景换了自绘控件:开双缓冲消闪烁,OnPaint 里用 Graphics.DrawLines 一支笔把整条线画完——GDI+ 内部走多边形连线,比循环调 DrawLine 快一个数量级。坐标换算自己做,x 按时间线性映射,y 按当前量程映射:

public class TrendPanel : Control
{
    private PointF[] _pts;   // 已经降采样到像素量级的点
    public TrendPanel()
    {
        DoubleBuffered = true;   // 等价 SetStyle 双缓冲三件套
        SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
               | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
    }
    protected override void OnPaint(PaintEventArgs e)
    {
        var g = e.Graphics;
        g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
        using (var pen = new Pen(Color.FromArgb(0,90,170), 1.2f))
        {
            if (_pts != null && _pts.Length > 1)
                g.DrawLines(pen, _pts);   // 一笔画完,不要 foreach DrawLine
        }
    }
}
  • 缩放平移时只重算可见点的像素坐标数组,然后 Invalidate,不要重建数据对象
  • 必须保留 Chart 控件做细节分析页也可以——总览页用自绘轻控件,分析页用 Chart,各取所长

第四刀:实时曲线用环形缓冲,别让点数无限涨

第 4 刀实时层:固定容量环形数组 + 定时刷新内存 680MB → 90MB

实时监控曲线还有个慢性病:每来一个点就 Add 一次、Invalidate 一次,跑一晚上点数涨到几十万,内存和重绘量一起爆。做法是定一个显示窗口容量(比如屏幕 2000 像素 ×2 = 4000 点),用环形数组存最新 4000 个点,新点覆盖最老点,对象数量恒定不增长;刷新用 500ms~1s 的定时器统一重绘,数据采集线程绝不碰 UI,采集只管往环形数组写。这样内存不随时间涨,重绘量永远是 4000 点封顶。

优化前后实测对比

指标(工控机实测)优化前优化后
单日曲线加载(17万点)12.4 秒0.3 秒
缩放/平移帧率0.5 FPS 拖影30+ FPS 跟手
窗体内存峰值680 MB90 MB
整月曲线(180万点)直接超时无响应1.1 秒(分桶包络)
超限尖峰保真min/max 包络无丢失

你的曲线卡在哪一刀?

回去做个一分钟的排查:在曲线加载代码前后加 Stopwatch,把"查询、数据填充、加点重绘"三段时间分别打出来。哪段长先砍哪段——但记住这个项目的共性结论:屏幕只有 1000 多列像素,任何让参与绘制的点数远超像素数的设计都是浪费。先把"取数按视口、点数按像素"这两条做到,八成卡顿当场消失。剩下的尖峰保真、实时内存问题,再按第二、第四刀补。