序數(shù)據(jù)可視化高效方案)
先交代一個(gè)背景我前陣子做監(jiān)控系統(tǒng)的可視化改造單臺(tái)服務(wù)器一天就能產(chǎn)生上百萬條時(shí)序指標(biāo)瀏覽器直接把折線圖渲染到卡死。當(dāng)時(shí)第一反應(yīng)是那就每隔N個(gè)點(diǎn)抽一個(gè)唄結(jié)果抽出來的曲線把幾個(gè)關(guān)鍵毛刺全丟了排查問題的時(shí)候差點(diǎn)被誤導(dǎo)。后來?yè)Q成了LTTB降維擬合算法同樣的數(shù)據(jù)量降到原來的千分之一波形輪廓幾乎原樣保留圖也秒開了。這篇文章就圍繞LTTB這個(gè)時(shí)間序列降維算法把我踩過的坑、拆過的源碼、調(diào)過的參數(shù)全部整理出來給同樣被大數(shù)據(jù)量時(shí)序可視化折磨的人一個(gè)能直接抄作業(yè)的方案。1. 等間隔抽樣把尖峰抽沒了時(shí)間序列“可視化降采樣”到底在解決什么問題1.1 你現(xiàn)在的抽樣方式可能在親手毀掉關(guān)鍵特征先明確一個(gè)概念這里說的降采樣Downsampling不是機(jī)器學(xué)習(xí)里那種降維而是把一條時(shí)間序列的點(diǎn)的數(shù)量減少同時(shí)盡量保留原始曲線的視覺特征。應(yīng)用場(chǎng)景非常具體監(jiān)控指標(biāo)圖表、傳感器數(shù)據(jù)回放、金融K線展示、數(shù)據(jù)庫(kù)查詢結(jié)果前端渲染。無論后端存儲(chǔ)多強(qiáng)瀏覽器能畫出來的點(diǎn)數(shù)是有上限的尤其在 Canvas 2D 上繪制幾十萬點(diǎn)哪怕能畫出來交互縮放、tooltip 響應(yīng)也會(huì)卡成PPT。常見的降采樣方式有三種我一個(gè)個(gè)說缺點(diǎn)等間隔抽樣每隔固定數(shù)量取一個(gè)點(diǎn)。實(shí)現(xiàn)最簡(jiǎn)單但完全無視數(shù)據(jù)的形態(tài)變化。信號(hào)在某段劇烈波動(dòng)時(shí)這段的特征點(diǎn)可能被全部跳過信號(hào)在某段平緩時(shí)又會(huì)留下大量冗余點(diǎn)。最大最小值抽樣按桶取最大值和最小值。對(duì)峰值保留比較好但會(huì)讓平緩信號(hào)看起來像鋸齒而且對(duì)尖峰這種單點(diǎn)異常不敏感沒辦法區(qū)分這個(gè)極值是真的毛刺還是離群噪音。滑動(dòng)平均/重采樣本質(zhì)是低通濾波把曲線平滑掉。對(duì)趨勢(shì)展示還行但對(duì)抖動(dòng)分析和異常定位非常不友好——你需要看到的恰恰就是那些被平均掉的細(xì)節(jié)。這些方式在數(shù)據(jù)量小的時(shí)候差異不明顯可一旦數(shù)據(jù)量到百萬級(jí)、而你只能保留一兩千個(gè)點(diǎn)時(shí)差別就是波形還在和波形面目全非的區(qū)別。1.2 拿三角形面積當(dāng)信息量的判據(jù)這個(gè)思路很巧妙LTTBLargest-Triangle-Three-Buckets最大三角形三桶算法的核心思想是Norway的一個(gè)工程師在2013年提出的。它的切入角度跟上面幾種抽樣完全不同不再用每N個(gè)點(diǎn)取一個(gè)的機(jī)械規(guī)則而是把一個(gè)點(diǎn)對(duì)曲線形狀的貢獻(xiàn)度量化為三角形面積。你想象一下在曲線上找一個(gè)點(diǎn)這個(gè)點(diǎn)與前后兩個(gè)參考點(diǎn)連起來能構(gòu)成一個(gè)三角形。如果這個(gè)點(diǎn)嚴(yán)重偏離前后兩個(gè)點(diǎn)的連線三角形面積就大說明這個(gè)點(diǎn)攜帶了重要的形狀信息如果這個(gè)點(diǎn)幾乎落在連線上三角形面積趨近于零那它就是冗余的丟掉也不影響視覺。LTTB每次迭代都選擇當(dāng)前桶里能構(gòu)成最大三角形面積的那個(gè)點(diǎn)作為代表點(diǎn)這樣保留下來的點(diǎn)就是一條在盡力模擬原始曲線形狀的點(diǎn)集。這個(gè)思路好在哪里它把一個(gè)模糊的視覺保真度問題轉(zhuǎn)成了每一步都可計(jì)算的最大面積問題。它不需要全局優(yōu)化而是采用貪心策略每一步都選看起來最不可能被丟棄的點(diǎn)。雖然理論上不一定全局最優(yōu)但實(shí)測(cè)效果已經(jīng)足夠好這也是它能被Grafana、InfluxDB等主流時(shí)序系統(tǒng)內(nèi)置使用的根本原因。2. LTTB的迭代邏輯與三角形面積計(jì)算從公式到逐行代碼2.1 算法完整流程拆解為了讓你徹底搞懂我把算法流程拆成一步步來看。假設(shè)原始序列有 n 個(gè)點(diǎn)目標(biāo)保留 threshold 個(gè)點(diǎn)且 threshold 小于 n。第一步固定首尾點(diǎn)。原本序列的第一個(gè)點(diǎn)和最后一個(gè)點(diǎn)必須保留。原因很直觀如果連首尾都丟了整條曲線的起點(diǎn)和終點(diǎn)就變了時(shí)間范圍也變了這對(duì)時(shí)間軸對(duì)齊是致命的。所以實(shí)際需要從中間的 n-2 個(gè)點(diǎn)里選出 threshold-2 個(gè)點(diǎn)。第二步計(jì)算桶大小。bucket_size (n - 2) / (threshold - 2)。這個(gè)值的含義是把中間 n-2 個(gè)點(diǎn)平均分給 threshold-2 個(gè)桶每個(gè)桶大約包含多少個(gè)原始點(diǎn)。注意這里的除法結(jié)果是浮點(diǎn)數(shù)后面要用 floor 函數(shù)取整來確定桶邊界所以各桶實(shí)際點(diǎn)數(shù)會(huì)有微小差異這是正?,F(xiàn)象不必強(qiáng)迫每個(gè)桶大小完全一致。第三步逐桶掃描。對(duì)第 i 個(gè)桶i 從 0 開始確定當(dāng)前桶的原始點(diǎn)范圍確定下一個(gè)桶的參考點(diǎn)經(jīng)典 LTTB 取下一個(gè)桶內(nèi)所有點(diǎn)的平均值作為第三個(gè)點(diǎn)以上一個(gè)已選點(diǎn)為三角形的第一個(gè)頂點(diǎn)以下一桶平均點(diǎn)為第三個(gè)頂點(diǎn)遍歷當(dāng)前桶內(nèi)每一個(gè)候選點(diǎn)作為第二個(gè)頂點(diǎn)用叉積公式計(jì)算三角形面積面積最大的那個(gè)點(diǎn)就是當(dāng)前桶的代表點(diǎn)加入結(jié)果集。第四步遍歷完所有桶后結(jié)果集即為降采樣后的序列。三角形面積計(jì)算用的是叉積公式。三個(gè)點(diǎn) A(x1,y1)、B(x2,y2)、C(x3,y3) 圍成的三角形面積為S |(x2 - x1)(y3 - y1) - (x3 - x1)(y2 - y1)| / 2因?yàn)榕判驎r(shí)所有三角形的分母都是 2不影響面積大小的相對(duì)關(guān)系所以代碼里一般省略除以2直接比較叉積絕對(duì)值。2.2 Python實(shí)現(xiàn)與坑點(diǎn)說明下面給出一份可直接運(yùn)行的 LTTB Python 實(shí)現(xiàn)。我用的是 NumPy 向量化思路盡量兼顧可讀性與性能。import numpy as np def lttb_downsample(x, y, threshold): LTTB 降采樣 :param x: 時(shí)間戳或x軸數(shù)值數(shù)組 :param y: 指標(biāo)值數(shù)組 :param threshold: 目標(biāo)保留點(diǎn)數(shù) :return: (采樣后的x, 采樣后的y) n len(x) if threshold n: return x, y if threshold 3: raise ValueError(threshold必須大于等于3) data np.column_stack((x, y)) bucket_size (n - 2) / (threshold - 2) sampled np.empty((threshold, 2)) sampled[0] data[0] sampled[-1] data[-1] selected 1 # 已選點(diǎn)數(shù)sampled[0]已占位 for i in range(threshold - 2): # 當(dāng)前桶的索引范圍 range_start int(np.floor((i 1) * bucket_size)) 1 range_end int(np.floor((i 2) * bucket_size)) 1 range_end min(range_end, n - 1) # 防止越界 # 下一個(gè)桶的索引范圍用于計(jì)算參考點(diǎn) next_start int(np.floor((i 2) * bucket_size)) 1 next_end int(np.floor((i 3) * bucket_size)) 1 next_end min(next_end, n) if next_start n: if next_end next_start: avg_point data[next_start:next_end].mean(axis0) else: avg_point data[min(n - 1, next_start)] else: avg_point data[n - 1] prev_point sampled[selected - 1] max_area -1.0 best_point None for idx in range(range_start, range_end): point data[idx] # 叉積計(jì)算面積省略0.5不影響結(jié)果 area abs( (point[0] - prev_point[0]) * (avg_point[1] - prev_point[1]) - (point[1] - prev_point[1]) * (avg_point[0] - prev_point[0]) ) if area max_area: max_area area best_point point sampled[selected] best_point selected 1 return sampled[:, 0], sampled[:, 1]代碼里我埋了幾個(gè)實(shí)際使用中容易被坑的地方單獨(dú)說明坑1range_end 需要做 min 越界保護(hù)。最后一次循環(huán)時(shí)range_end 可能越過數(shù)組末尾如果不限制會(huì)導(dǎo)致索引越界。我在代碼里加了min(range_end, n - 1)防止最后一個(gè)桶掃描時(shí)訪問不存在的點(diǎn)???空桶問題。當(dāng)數(shù)據(jù)量小、threshold 又相對(duì)較大時(shí)某些桶可能沒有候選點(diǎn)此時(shí)best_point會(huì)是 None。更穩(wěn)的做法是如果掃描到的范圍沒有有效點(diǎn)直接保留當(dāng)前桶的邊界點(diǎn)作為替代。這個(gè)邊界情況在開源實(shí)現(xiàn)里處理方式各不相同但生產(chǎn)環(huán)境一定要處理否則會(huì)拋空指針???NaN 值。如果原始數(shù)據(jù)里混入 NaN平均值會(huì)變成 NaN三角形面積也會(huì)變成 NaN導(dǎo)致比較結(jié)果異常。建議降采樣之前先做一次預(yù)處理用前向填充或線性插值把 NaN 處理掉???數(shù)據(jù)必須按 x 排序。LTTB 的桶劃分依賴相鄰點(diǎn)的概念如果原始時(shí)間戳亂序整個(gè)段落關(guān)系就亂了降采樣結(jié)果沒有任何意義。這點(diǎn)特別容易被剛接觸的人忽略——從數(shù)據(jù)庫(kù)查出來的數(shù)據(jù)有時(shí)不會(huì)主動(dòng)排序。3. threshold參數(shù)、邊界條件與MinMax變體真實(shí)應(yīng)用中躲不開的細(xì)節(jié)3.1 threshold 到底選多少才合理這是我在社區(qū)里被問得最多的一個(gè)問題。很多人直接把 threshold 設(shè)置成感覺上差不多的數(shù)字結(jié)果要么圖還是卡要么波形丟失嚴(yán)重。我的經(jīng)驗(yàn)是threshold 與最終展示的圖表寬度強(qiáng)相關(guān)。假設(shè)你的圖表容器寬度是 1600px而每個(gè)數(shù)據(jù)點(diǎn)至少要占一個(gè)像素才有意義那么 threshold 設(shè)置在 2000 到 3000 之間就足夠了。超出這個(gè)范圍多余的點(diǎn)在屏幕上根本顯示不出來只會(huì)增加渲染壓力低于這個(gè)范圍則可能出現(xiàn)相鄰點(diǎn)跨越多個(gè)像素導(dǎo)致形狀細(xì)節(jié)丟失。如果你是在服務(wù)端做預(yù)降采樣而后端不確定前端的具體展示寬度可以按如下策略自適應(yīng)數(shù)據(jù)量在 1000 以下不降采樣直接返回?cái)?shù)據(jù)量在 1000 到 100000threshold 設(shè)為 2000 到 3000數(shù)據(jù)量在 100000 到 1000000threshold 設(shè)為 3000 到 5000數(shù)據(jù)量超過一百萬先粗篩到 5 萬點(diǎn)再用 LTTB 降到 3000 點(diǎn)。這里先粗篩的目的是減少 LTTB 的掃描開銷后面我會(huì)單獨(dú)講。還有一種更精細(xì)的做法按曲線的局部復(fù)雜度動(dòng)態(tài)分配點(diǎn)數(shù)。先把原始序列切成若干段統(tǒng)計(jì)每段的方差或極差方差大的段多給一些目標(biāo)點(diǎn)數(shù)方差小的段少給。這樣能在同樣的總點(diǎn)數(shù)預(yù)算下進(jìn)一步保留波形特征。代價(jià)是實(shí)現(xiàn)復(fù)雜度上來了大多數(shù)場(chǎng)景用不上但如果你要展示的是高頻振動(dòng)疊加低頻趨勢(shì)的復(fù)合信號(hào)這套方案很值得試。3.2 常見變體與適用邊界經(jīng)典 LTTB 的參考點(diǎn)是下一個(gè)桶的平均值。這個(gè)選擇在大多數(shù)情況下效果不錯(cuò)但有一個(gè)天然弱點(diǎn)平均值會(huì)讓參考點(diǎn)偏向桶的質(zhì)心位置如果下一個(gè)桶內(nèi)恰好有一個(gè)極窄的尖峰平均值可能完全體現(xiàn)不出這個(gè)尖峰的存在。為了解決這個(gè)問題社區(qū)出現(xiàn)了幾個(gè)有價(jià)值的變體我在下面做個(gè)對(duì)比變體名稱核心改動(dòng)適用場(chǎng)景不足經(jīng)典 LTTB下一桶參考點(diǎn)為桶內(nèi)平均值常規(guī)監(jiān)控曲線、平滑趨勢(shì)對(duì)桶內(nèi)單點(diǎn)尖峰不夠敏感MinMax-LTTB交替使用下一桶的最大值與最小值作為參考點(diǎn)高頻毛刺、尖峰異常檢測(cè)更易保留噪音曲線略抖加權(quán)三角面積面積計(jì)算時(shí)乘上相鄰點(diǎn)距離權(quán)重時(shí)間戳不均勻分布的數(shù)據(jù)參數(shù)調(diào)起來麻煩分桶自適應(yīng)LTTB桶大小根據(jù)局部密度動(dòng)態(tài)變化數(shù)據(jù)分布極度不均勻?qū)崿F(xiàn)復(fù)雜難以調(diào)優(yōu)我在一個(gè)網(wǎng)絡(luò)延遲監(jiān)控項(xiàng)目里做過對(duì)比原始數(shù)據(jù)里每隔一小段就有一個(gè)明顯毛刺經(jīng)典 LTTB 把這些毛刺的幅度平均掉了 20% 左右而 MinMax-LTTB 幾乎原樣保留。如果你關(guān)注的恰恰是異常點(diǎn)直接上 MinMax-LTTB如果關(guān)注的是整體趨勢(shì)經(jīng)典 LTTB 更平滑視覺效果更好。3.3 它不擅長(zhǎng)什么降采樣不等于特征提取這里必須潑一盆冷水。LTTB 做的是可視化保形不是統(tǒng)計(jì)分析。它選出的點(diǎn)分布不均勻不能拿去做聚合計(jì)算求和、平均、分位數(shù)因?yàn)椴煌狞c(diǎn)代表的數(shù)據(jù)量不同直接統(tǒng)計(jì)會(huì)產(chǎn)生偏差。舉一個(gè)真實(shí)的教訓(xùn)我曾經(jīng)把降采樣后的數(shù)據(jù)直接喂給一個(gè)異常檢測(cè)模型結(jié)果模型召回率明顯下降。原因是降采樣把一些短時(shí)但真實(shí)的小概率事件給優(yōu)化掉了——LTTB 選擇面積最大的點(diǎn)等價(jià)于它天然偏好那些視覺反差大的樣本而機(jī)器學(xué)習(xí)恰恰需要保留小概率事件的分布。所以可視化降采樣用 LTTB效果好特征提取/模型訓(xùn)練不要用 LTTB用均勻采樣加統(tǒng)計(jì)特征更靠譜時(shí)序預(yù)測(cè)前置處理也不要直接拿 LTTB 結(jié)果訓(xùn)練模型它可能破壞自相關(guān)性。這個(gè)邊界很多人沒意識(shí)到結(jié)果在生產(chǎn)環(huán)境里踩了坑。4. 幾百萬數(shù)據(jù)點(diǎn)秒出圖工程落地的性能優(yōu)化與集成方式4.1 后端服務(wù)化接入方案在實(shí)際項(xiàng)目里我通常會(huì)把 LTTB 封裝成一個(gè)獨(dú)立的降采樣服務(wù)或公共函數(shù)通過 HTTP 接口給前端或其他服務(wù)調(diào)用。以 FastAPI 為例接入方式大概是這樣的from fastapi import FastAPI, Query from pydantic import BaseModel app FastAPI() class DownsampleRequest(BaseModel): x: list[float] y: list[float] threshold: int app.post(/api/downsample) def downsample_api(req: DownsampleRequest): sx, sy lttb_downsample( np.array(req.x), np.array(req.y), req.threshold ) return {x: sx.tolist(), y: sy.tolist()}在接口層還需要加一層緩存。監(jiān)控系統(tǒng)里同一個(gè)時(shí)序圖往往會(huì)被多個(gè)人反復(fù)查看如果每次都重新計(jì)算純屬浪費(fèi) CPU。我用的是極簡(jiǎn)方案以指標(biāo)名 起止時(shí)間戳 threshold作為緩存 key把降采樣結(jié)果存到 Redis 里設(shè)置 5 分鐘過期。這樣即便有十幾個(gè) dashboard 同時(shí)刷新后端也扛得住。4.2 性能實(shí)測(cè)一百萬點(diǎn)降到一千點(diǎn)要多久光說理論不行我把我本地實(shí)測(cè)的數(shù)據(jù)給你參考。測(cè)試環(huán)境是 MacBook Pro M1Python 3.10數(shù)據(jù)量 100 萬點(diǎn)目標(biāo)降到 1000 點(diǎn)。結(jié)果大概是這樣實(shí)現(xiàn)方式耗時(shí)說明純 Python 逐點(diǎn)循環(huán)上面代碼350ms ~ 600ms慢在中間層 for 循環(huán)逐點(diǎn)掃描NumPy 部分向量化120ms ~ 180ms把三角形面積結(jié)算改成數(shù)組運(yùn)算先等間隔粗篩到 5 萬再 LTTB 精降30ms ~ 50ms工程最推薦方案先粗篩再精降的方案效果幾乎和直接全量 LTTB 一樣但速度快一個(gè)數(shù)量級(jí)。原因也不難理解LTTB 每個(gè)桶內(nèi)要逐點(diǎn)掃描候選點(diǎn)原始點(diǎn)越多、掃描次數(shù)越多。粗篩到 5 萬點(diǎn)之后桶內(nèi)候選點(diǎn)變少面積計(jì)算次數(shù)大幅下降而粗篩本身因?yàn)槭菬o腦等間隔取點(diǎn)代價(jià)極低。實(shí)測(cè)波形對(duì)比中粗篩精降和全量 LTTB 在視覺上幾乎沒有區(qū)別。4.3 我在實(shí)際項(xiàng)目中沉淀的幾個(gè)技巧技巧1重復(fù)降采樣時(shí)做緩存而不是每次重新計(jì)算。監(jiān)控圖表的降采樣往往伴隨同一個(gè)指標(biāo)、同一個(gè)時(shí)間范圍、多個(gè)不同 threshold的組合請(qǐng)求。我在服務(wù)端做了一個(gè)雙重緩存先按原始數(shù)據(jù)版本號(hào)緩存粗篩結(jié)果再按 threshold 緩存 LTTB 結(jié)果命中率很高。技巧2前端渲染時(shí)配合分塊繪制。就算降采樣到了 2000 點(diǎn)如果 canvas 上還疊加了多條曲線、多個(gè)縮放層級(jí)幀率依然可能不夠。我的做法是把降采樣后的點(diǎn)按 x 坐標(biāo)切成多個(gè) chunk每次只繪制可視區(qū)域內(nèi)的 chunk滾動(dòng)時(shí)動(dòng)態(tài)加載相鄰 chunk。這個(gè)配合 LTTB 使用體驗(yàn)提升非常明顯。技巧3對(duì)時(shí)間戳做歸一化再計(jì)算。某些時(shí)間戳是毫秒級(jí) Unix 時(shí)間數(shù)值非常大x 軸和 y 軸的數(shù)值量級(jí)可能差出好幾個(gè)數(shù)量級(jí)導(dǎo)致三角形面積被某一軸的數(shù)值主導(dǎo)。比如 x 是 1700000000000 毫秒y 是 0.3叉積計(jì)算時(shí) y 方向的貢獻(xiàn)幾乎被 x 淹沒算法退化成只看 x 距離。解決辦法很簡(jiǎn)單計(jì)算前對(duì) x 做 min-max 歸一化或者統(tǒng)一轉(zhuǎn)成秒級(jí)并減去基線偏移量。這個(gè)坑我在第一次接入真實(shí)監(jiān)控?cái)?shù)據(jù)時(shí)就踩過波形看起來貌似合理但總感覺細(xì)節(jié)不對(duì)排查了很久才發(fā)現(xiàn)是量綱問題。技巧4閾值低于 3 時(shí)直接降級(jí)。threshold 小于 3 時(shí)LTTB 無法正常工作因?yàn)槭孜颤c(diǎn)占了兩個(gè)中間至少需要一個(gè)點(diǎn)。我在封裝函數(shù)里對(duì)這個(gè)情況做了降級(jí)處理直接退化為等間隔抽樣保證接口不報(bào)錯(cuò)。技巧5Grafana、InfluxDB 等系統(tǒng)已經(jīng)內(nèi)置了 LTTB。如果你用的監(jiān)控平臺(tái)是 Grafana在查詢面板的數(shù)據(jù)源選項(xiàng)里很多時(shí)序數(shù)據(jù)庫(kù)已經(jīng)提供了 LTTB 降采樣選項(xiàng)。了解算法原理之后你就能明白那個(gè)下拉框背后發(fā)生了什么也更容易判斷不同選項(xiàng)的適用場(chǎng)景。最后再分享一個(gè)小技巧我習(xí)慣在項(xiàng)目里把 LTTB 和等間隔抽樣同時(shí)保留做成一個(gè)可切換的采樣器。日常巡檢、宏觀趨勢(shì)看等間隔抽樣就夠了一旦需要排查毛刺、定位抖動(dòng)、分析異常就切到 LTTB。兩者的目標(biāo)不同沒有誰完全替代誰關(guān)鍵是搞清楚手里的數(shù)據(jù)要拿來看什么。