1. 折线图谁都会画,可时序数据一上来就先给你三个下马威
“时序数据的可视化”这几个字,很多人第一反应就是一行plt.plot(x, y)收工。我第一次接这类需求时也这么想,结果交付当天被问了三句话:为什么尖峰没了?为什么两点之间是一条笔直的斜线?为什么我选中的这段时间,图上显示的日期差了一天?这三个问题没有一个跟“画图 API”有关,全都出在数据进图之前的那几步。所以这个系列我不打算从“用哪个库”讲起,那是最不值一提的部分。我想先把你按在椅子上,把时序数据可视化这件事里最容易被跳过、又最容易翻车的环节,一块一块拆开。
先把范围说清楚:这篇讲的是“把图画对、画准、画得住”,也就是从一张原始事件表,走到一张能被人信任的折线/面积/热力图的全过程。适合谁看?写过几行绘图代码、但被时间轴、缺失值、海量点折磨过的人;也适合那种“图能出来但总觉得哪里不对”的人。至于交互联动、仪表盘工程化、实时流式刷新,那是后面几篇的事,这里只在必要处埋个伏笔。
我先把结论摆在前面:时序可视化的绝大部分工作量不在“画”,而在“对齐”和“取舍”。对齐是指时间轴、采样节奏、多序列的基准要一致;取舍是指几万个点塞进几百像素宽的画布,你必须决定留下谁、扔掉谁。这两件事做错了,图画得再漂亮也是误导。
提醒:任何一张时序图,在给别人看之前,先问自己一句“如果这个人只扫三秒,他会得出什么结论”。如果那个结论跟你掌握的事实不符,问题一定在数据处理层,不在配色。
1.1 时间戳进到程序里的三种形态,出错概率最高的是第二种
时间字段在源系统里的样子,基本逃不出三类:字符串(2024-03-05 14:23:01)、整数时间戳(秒或毫秒)、以及带时区信息的标准时间对象。第一类和第三类都还好,真正阴人的是第二类——整数时间戳。它本身不带单位信息,你在文档里看到“ts”,鬼知道是秒还是毫秒。我曾经接过一份数据,字段名叫timestamp,前一万行都是 10 位数(秒),到某个时间点之后突然变成 13 位(毫秒),原因是上游某个服务升级换了实现。如果你不做类型和量级的校验,画出来的图会是这个样子:前面一大段正常,中间突然塌陷成一条贴着零轴的直线,后面又跳起来。看着像业务出了大事故,实际上是数据打架。
我的做法是先做一次量级体检,别嫌土,很管用:
import pandas as pd def normalize_ts(s: pd.Series) -> pd.Series: s = pd.to_numeric(s, errors="coerce") # 13 位按毫秒处理,10 位按秒处理,超出范围的直接判为异常 med = s.dropna().median() if med > 1e11: # 毫秒级 return pd.to_datetime(s, unit="ms", utc=True) return pd.to_datetime(s, unit="s", utc=True)统一走 UTC 再往下走,是我踩过坑之后定下的规矩。为什么?因为“本地时间”这四个字在有夏令时的地区会让某一天凭空多出或少了 3600 秒,如果你的横轴按本地时间等距排布,那一天就会画出一段诡异的拉伸或者重叠。先在 UTC 下把所有计算、聚合、切片做完,只在最终渲染刻度标签的那一步转成展示时区,这条链路是清晰的。
还有个小陷阱:pd.to_datetime遇到格式混杂的字符串时,默认行为在不同版本里不完全一致,有的会静默把解析失败的置为NaT,有的会抛错。保险起见把errors="coerce"显式写上,然后统计一下NaT的比例,超过千分之一就得回头查源头,别硬着头皮往下画。
1.2 等间隔假设:你那条“平滑上升”的线可能是假的
绝大多数绘图库默认把数据点当作等间距处理,横轴只是给点编号。当你把真实时间戳传进去,它才按比例摆放。但很多人图省事只传了y,横轴用索引。这时候如果原始数据在某个时段断了两个小时没上报,图上完全看不出来——那两小时被压缩成了一个点的间距,曲线依旧平滑。这就是我说的“假的斜率”。
举个具体例子:一个设备的温度序列,正常情况下每 5 分钟一条,上午 10 点到 12 点因为网络问题只上报了三条。按索引画,你会看到温度在这“一小格”里缓慢爬升;按真实时间画,你会看到它先是一小段陡升,然后一条横跨两小时的断线,再接上。这两个观感会导向完全不同的运维判断。
所以横轴必须用真实时间。用 pandas 的话,把时间列设为索引之后,绘图库会自己处理间隔:
df = df.set_index("ts").sort_index() df["value"].plot() # 横轴自动按真实时间比例排布,缺口处留白顺带一提,sort_index()那一步不要省。乱序的时间戳在某些绘图库里会导致线条来回穿越,看起来像心电图乱跳,排查半天以为是数据异常,其实是没排序。
1.3 采样率和展示粒度对不上,是“看不出趋势”的元凶
第三个下马威是粒度。原始数据可能是秒级,但你要看的是“过去 30 天的整体趋势”。这时候如果你把 30 天 × 86400 秒 ≈ 260 万个点直接扔进画布,会发生两件事:渲染卡死,以及视觉上变成一条粗糊糊的色带,什么趋势都看不出来。
这里有个必须建立的心智模型:屏幕宽度决定了你能表达的信息量上限。一块 1200 像素宽的图,扣掉边距,有效绘图区大概 1100 像素。这意味着你最多也就区分 1100 个横向位置。你塞 260 万个点进去,平均每个像素要挤 2300 多个点,那它呈现的其实是这些点的“密度分布”,而不是曲线。想明白这一点,你就知道降采样不是“偷懒”,而是让数据的表达精度匹配画布的物理精度。这部分后面第 3 章会展开讲具体算法。
2. 从原始事件表到可画的数据:清洗环节比画图环节更耗时间
我做过一个粗略统计,在一个典型的时序可视化需求里,纯写绘图代码的时间大概占两成,剩下八成里,一半花在跟数据源核对口径,一半花在处理缺失、重复、对齐。这一章就把这些“不上图但决定图对不对”的活儿讲透。
2.1 缺失的三种成因,决定了三种完全不同的补法
缺失值最忌讳的就是一句fillna(method="ffill")走天下。你得先分清它是怎么缺的。
第一种是“真的没有”。比如传感器坏了、服务没启动。这时候补值会凭空造出一个不存在的状态,非常危险。正确做法是留白,让线断开。视觉上的断裂本身就是信息:这里有一段不可知。
第二种是“采集周期大于显示周期”。比如数据是每小时一条,但你按分钟做重采样,自然一堆空。这种情况应该先用resample到原始频率再画,或者明确用“阶梯线”(step plot)表达“这个小时内值保持不变”。注意,ffill在语义上等价于“保持上一个值”,它和“阶梯线”是一回事,用阶梯线画出来比斜线更诚实。
第三种是“上报丢失但值确实在变化”。这种最难办,因为你既不能装作没有,也不能假装知道。我一般的处理是线性插值,但仅限短缺口(比如连续缺失不超过 3 个采样点),长缺口一律留白。而且我会在图上给这些插值段标一个浅色底纹或者虚线,让看图的人知道“这一段是猜的”。
def fill_short_gaps(s: pd.Series, max_gap: int = 3) -> pd.Series: # 只对短缺口做线性插值,长缺口保持 NaN 让线断开 mask = s.isna() groups = (mask != mask.shift()).cumsum() gap_len = mask.groupby(groups).transform("sum") short = mask & (gap_len <= max_gap) filled = s.copy() filled[short] = s.interpolate(method="time")[short] return filled这段逻辑的核心是“按连续缺口的长度分组”,而不是简单地看总数。mask != mask.shift()这个技巧在 pandas 里判断“连续段”非常好用,值得记住。
提醒:插值一定要配合展示标记。默默插值然后画一条光滑曲线,是时序可视化里最不负责的做法,没有之一。
2.2 处理顺序错了,后面全是白工:去重、排序、时区、过滤的先后
这几个步骤的先后顺序不是随便排的,我固定的顺序是:统一时区 → 去重 → 排序 → 过滤范围 → 补缺口 → 聚合。
先说去重。时序数据里重复时间戳很常见,比如两个采集器同时上报,或者重试机制导致同一条消息被消费两次。去重不能简单drop_duplicates("ts"),因为你要决定保留哪一条。如果是累加型指标(比如请求总数),保留最后一条;如果是瞬时值(比如温度),保留平均值或者最后一条都说得过去,但必须明确写下你的选择,因为同一个时间戳对应两个不同的值,本身就是数据质量问题,掩盖它只会让后面的人更糊涂。
再说过滤范围。这个必须放在聚合之前,否则你会先对全量数据做一次昂贵的重采样,然后才砍掉 95% 用不上的时间段。我见过一个脚本,每次跑都要四十多秒,查下来就是顺序反了。
最后是聚合。聚合方式取决于指标语义:计数型用sum,比率型要用分子分母分别求和再相除(直接对百分比求平均是经典错误),状态型用last,传感器读数用mean或者median。
| 指标类型 | 错误聚合 | 正确聚合 | 原因 |
|---|---|---|---|
| 请求数、错误数 | 无 | sum | 可累加 |
| 成功率、命中率 | mean | 分子分母分别sum再相除 | 比率不可直接平均 |
| 在线状态、配置版本 | mean | last或按状态分段 | 状态无常量含义 |
| 温度、压力 | 无 | mean或median | 直接平均即可,中位数抗离群 |
| 耗时 P99 | mean | 先按桶统计再合并 | 分位数不可直接平均 |
这张表我贴在工作区墙上,因为“对百分比求平均”这个错误我至少见过五六次,每次都要重新解释一遍。
2.3 多序列对比时,先对齐再画,别让图自己去猜
多序列的场景下,最容易犯的错是把几个不同频率的序列塞进同一个 DataFrame,然后靠绘图库自己去对齐索引。结果就是有的行全是 NaN,有的图在莫名其妙的地方断开。正确做法是明确地用resample对齐到统一的时间栅格:
grid = pd.date_range(start, end, freq="5min", tz="UTC") a = series_a.resample("5min").mean().reindex(grid) b = series_b.resample("5min").sum().reindex(grid) aligned = pd.DataFrame({"a": a, "b": b})这里用reindex而不是join,是为了保证时间栅格严格等距——后续如果你要画热力图或者做差分、做滑动窗口,等距栅格是前提。另外要注意resample的label和closed参数,默认是左闭右开、标签在左边界,如果你的业务口径是“这一格的统计覆盖到右边界”,就得改。这个参数错了,整张图会整体平移半格,肉眼极难发现,但对齐多个数据源时就会出现“A 比 B 早半格”的假象,进而得出一个假的相关性结论。
3. 把二十万个点压进一千个像素:降采样的三种思路,别只会抽稀
这一章是这篇的核心。降采样不是简单地[::10]取步长,那样做会把尖峰抹平、把毛刺留下,画出完全失真的形状。
3.1 朴素抽稀为什么会“吃尖峰”
假设你有一分钟的响应耗时数据,某天下午出现了一次持续 20 秒的抖动,耗时瞬间冲到 5000ms。如果你按每 100 个点取 1 个点,这 20 秒大概对应 20 个点,被抽中的概率只有 20% × 若干,很可能整段抖动一个点都没被选中,图上风平浪静。而实际业务方查的就是这次抖动。
这就是抽样与极值之间的矛盾:均匀抽样保证整体形状,但会丢极值;极值保留保证异常可见,但线条会变成锯齿。所以成熟的方案基本都是混合的。
3.2 三种方案对比:抽稀、均值聚合、极值保留
我把常用的方案列个表,你可以直接对着自己的场景选。
| 方案 | 实现复杂度 | 形状保真 | 极值保留 | 适用场景 |
|---|---|---|---|---|
| 等步长抽稀 | 极低 | 中 | 差 | 快速预览、数据本身平滑 |
| 时间桶均值 | 低 | 好 | 差(峰值被平均掉) | 看趋势、看均值水平 |
| 时间桶 min-max | 中 | 好 | 极好 | 看异常、看抖动、看毛刺 |
| LTTB | 中 | 很好 | 较好 | 通用场景,视觉最接近原图 |
| 分位数桶(P50/P95/P99) | 中 | 好 | 好(P99 抓长尾) | 耗时、延迟类指标 |
时间桶 min-max的思路特别朴素但极其有效:把数据按像素宽度切成若干桶,每个桶里只保留最小值和最大值两个点,按时间顺序输出。因为每个桶必然贡献两个点,画出来的线在视觉上能覆盖到桶内的真实波动范围,尖峰几乎不会丢。
import numpy as np def minmax_downsample(ts, values, n_buckets): n = len(ts) size = int(np.ceil(n / n_buckets)) out_t, out_v = [], [] for i in range(0, n, size): seg_t, seg_v = ts[i:i+size], values[i:i+size] if len(seg_v) == 0: continue idx = sorted({int(np.argmin(seg_v)), int(np.argmax(seg_v))}) for j in idx: out_t.append(seg_t[j]) out_v.append(seg_v[j]) return np.array(out_t), np.array(out_v)注意sorted({...})这里用集合去重再排序,是为了保证同一桶内两个点的输出顺序跟时间顺序一致,否则画出来的线会在桶内来回折返,形成细小的“Z”字杂物。这个细节我第一次写的时候就没处理,图放大之后能看到一堆毛刺,排查了很久才发现是输出顺序问题。
LTTB(最大三角形三桶法)稍微复杂一点,但效果确实好。它的思路是:把数据分成 N 个桶,每桶选一个点代表。选谁?用“三角形面积最大”来判定——用上一桶选中点、本桶候选点、下一桶均值点组成三角形,面积最大的那个候选点就是视觉上最“有信息量”的点。面积大意味着它在视觉轮廓上是转折点。
def lttb(ts, vs, n_out): n = len(ts) if n_out >= n or n_out < 3: return ts, vs sampled_t, sampled_v = [ts[0]], [vs[0]] every = (n - 2) / (n_out - 2) a = 0 for i in range(n_out - 2): # 下一桶的均值点,作为三角形的第三个顶点 ns, ne = int((i + 1) * every) + 1, min(int((i + 2) * every) + 1, n) avg_t = np.mean(ts[ns:ne]) if ne > ns else ts[-1] avg_v = np.mean(vs[ns:ne]) if ne > ns else vs[-1] # 当前桶范围 cs, ce = int(i * every) + 1, min(int((i + 1) * every) + 1, n - 1) if ce <= cs: ce = cs + 1 bx, by = ts[cs:ce], vs[cs:ce] area = np.abs((ts[a] - avg_t) * (by - vs[a]) - (ts[a] - bx) * (avg_v - vs[a])) a = cs + int(np.argmax(area)) sampled_t.append(ts[a]) sampled_v.append(vs[a]) sampled_t.append(ts[-1]) sampled_v.append(vs[-1]) return np.array(sampled_t), np.array(sampled_v)LTTB 的优点是整体轮廓几乎和原图一模一样,缺点是对“孤立尖峰”的保留不如 min-max——因为面积算法看的是三角形的相对形状,一个非常窄但很高的尖峰,如果两侧都很平,确实会被选中,但如果尖峰正好落在桶边界上,效果就会打折。我的实践是:看趋势用 LTTB,查异常用 min-max,看长尾用分位数桶,哪个都不敢说通吃。
3.3 降采样发生在哪一层,决定了你的接口长什么样
这是工程上更要紧的问题。降采样发生在后端查询时,还是前端渲染时?
后端降采样的好处是传输量小、渲染快,缺点是“缩放之后不清”——用户放大到某个时间窗,看到的还是聚合后的粗粒度数据,细节全无。前端降采样的好处是原始数据在手,缩放灵活,缺点是首屏传输可能几十兆。
我倾向的分层做法是:后端做粗粒度预聚合(分钟级、小时级、天级各存一份),前端根据当前视窗宽度选择粒度和做二次降采样。视窗宽度换成像素,按“每像素 2 个点”为目标算出需要多少个点,再从最接近的预聚合层取。这个逻辑在滚动缩放时可以做到很流畅,因为每一次请求的数据量都控制在几千点以内。
// 前端根据容器宽度估算期望点数 function targetPoints(canvasWidth, dpr = 1) { const px = canvasWidth * dpr; return Math.max(200, Math.floor(px * 2)); }canvasWidth * dpr这一步不能漏,高分屏下物理像素是逻辑像素的两倍,按逻辑像素算会导致图看起来偏糊。这个细节我在移动端项目里栽过一次,桌面端一切正常,手机上就是不够锐利。
4. 折线不是万能钥匙:五种时序图型各自解决什么
很多人手里只有一把锤子,看什么都像钉子,所有时序问题一律折线图。实际上不同的提问方式,对应完全不同的图型。
4.1 折线、面积、散点、热力、状态带的选择依据
折线适合“连续变化的数值随时间演进”,比如温度、内存占用、耗时。它的前提是相邻点之间有连续性假设。
面积图适合强调“累积量”或者“构成占比随时间的变化”。单序列面积图意义不大,堆叠面积图才有用,但堆叠超过 4 层基本就读不出中间层的值了,那时候考虑换成小倍数图。
散点图在时序场景下常被低估。当数据点本身很稀疏、或者你想看“分布随时间漂移”的时候,散点比折线诚实得多。比如一批任务的执行耗时,画成折线会把本来无关的任务连起来,产生“趋势”的错觉,画成散点则一目了然。
热力图(横轴时间、纵轴类别、颜色数值)适合高密度、多维度的情况。比如 7 天 × 24 小时的请求分布,或者几十个接口的耗时对比。它把“找规律”变成“找色块”,人眼对色块图案的敏感度远高于对多条线的比较。
状态带 / 甘特条适合离散状态,比如“运行/停止/告警”、“部署版本 v1/v2/v3”。用折线画状态是最常见的错误——它会在两个状态之间画出斜线,暗示存在过渡态,而实际上状态是突变的。正确做法是用阶梯线或者色带块。
# 状态序列用阶梯线,step 参数决定跳变发生的位置 ax.step(t, state, where="post", linewidth=1)where="post"表示“值保持到下一个时间点才变”,这符合绝大多数状态语义。如果写成默认的pre,整条线会左移一格,看着就像状态提前变了。
4.2 多序列对比:一张图十条线,等于一张图零条线
八条线叠在一起,颜色再讲究也没用,人眼追踪交叉曲线能力极差。我现在的默认做法是小倍数图(small multiples):共享横轴,纵向排列多个子图,每个子图一条线,纵轴可以独立也可以共享。
fig, axes = plt.subplots(nrows=4, ncols=1, sharex=True, figsize=(10, 6), gridspec_kw={"height_ratios": [1, 1, 1, 1]})sharex=True保证了时间轴严格对齐,这是小倍数图最有价值的地方——你可以用眼睛垂直扫过,看同一时刻各序列的状态。而且它天然解决了“纵轴量纲不同”的问题,不需要双轴。
4.3 双轴图的问题不在于难看,在于它在撒谎
双 Y 轴是我最反感的图表形式之一,原因不是审美,而是它可以任意制造出你想要的“相关性”。左边刻度一缩,右边刻度一放,两条本来无关的线立刻“完美吻合”。而且读者几乎不会去核对两边的刻度范围。
替代方案有三个:一是归一化到同一量纲(都转成相对基准的百分比),二是画差值或者比值序列(如果业务上确实关心“两者的关系”),三是用小倍数图并列展示,让人自己判断。
如果非要用双轴(比如上下级领导明确要求),我至少会做两件事:把两条线的颜色和对应坐标轴的刻度标签颜色绑成一致,以及在轴标题里写清楚单位。即便如此,我还是建议你在图注里加一句“左右轴刻度独立,请勿直接比较幅度”。
5. 坐标轴、刻度与悬停:那些决定图能不能被读懂的小事
图画完了,能跑通,不代表能读懂。这一章全是细节,但每一个细节我都见过真实翻车。
5.1 时间刻度的选择不是格式化问题,是选点问题
一个 30 天的窗口,横轴标 720 个“03-05 14:00”的标签,除了制造墨块没有任何价值。刻度要选“人类习惯的节点”:整点、整日、周一、月初。主流绘图库的自适应刻度器能做这件事,但在跨时区或者跨度极端(几秒到几年)时会抽风,这时候就得手动指定。
import matplotlib.dates as mdates # 跨度 > 30 天,标“月-日”;跨度 < 1 天,标“时:分” ax.xaxis.set_major_locator(mdates.AutoDateLocator(minticks=4, maxticks=8)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))minticks和maxticks限制刻度数量,是控制标签密度的第一道闸。第二道是标签旋转,跨年跨度下全写2024-03-05肯定挤,可以只在第一天标年月,其余标日。这个逻辑得自己写,库不管。
还有一个容易忽略的点:跨零点的窗口。如果你展示的是“昨天 18:00 到今天 06:00”,横轴标签如果不带日期,会看到 18、19…23、00、01,读者容易误以为在同一天。稳妥起见,跨天窗口统一带上日期,哪怕标签长一点。
5.2 悬停提示的信息密度:别只显示一个裸数字
交互图里的 tooltip 是信息密度最高的位置。只显示value: 42.7是不够的,我一般会包含:时间戳(带时区)、当前值、单位、以及与基线的对比(比如环比、同比、或者相对 P95 的位置)。但也不能塞太多,超过 5 行就没人看了。
多序列同图时,横向对齐的联动提示(unified hover)比每个点单独弹要好用得多,因为你可以一眼看到“同一时刻各序列分别是多少”。这个在主流图表库里通常是一个开关参数,记得打开。
// 前端图表配置里的联动提示思路 tooltip: { trigger: "axis", axisPointer: { type: "cross", snap: true }, }snap: true让指示线吸附到最近的数据点上,避免鼠标在两点之间时提示框闪烁。
5.3 阈值线、异常标注与配色:让图自己说话
一条合格的运维类时序图,应该让人不用问就看出“哪里不正常”。做法是加两条参考线:一条是告警阈值(实线,红色),一条是历史 P95(虚线,灰色)。有了这两条,任何一个点的高低都有了参照系,不需要读者去脑子里换算。
异常点的标注我倾向于用标记点而不是变色线段,因为变色线段在密集数据里很难分辨。用一个小圆点加上简短的文字标签(比如“重启”“发布”),事件就挂在时间轴上了。这个做法在排查类场景里价值极高——你能立刻看出“抖动发生在上线之后 3 分钟”。
配色上,我的原则是:连续量用单色渐变的明度差,类别量用色相区分,且不超过 6 种。红绿搭配要避免,不是矫情,色觉异常人群比例不低,红绿是最难分辨的一对。要表达“好/坏”用蓝橙比用绿红安全得多。
6. 我在真实项目里栽过的几个跟头,以及一张上线前的自查表
讲完方法,说几个具体经历。这些不是从文档里抄的,是实实在在调试到半夜的那种。
6.1 三次印象深刻的翻车
第一次是“时区导致的过期告警”。某次做一个跨区域的指标看板,原始数据都是 UTC,我在前端展示时按浏览器本地时区转换。结果有位同事带着笔记本出差,系统时区变了,他截图发群里说“这个指标提前两小时到峰值”。查了半天数据没问题,是展示层的事。后来我们把展示时区做成看板级别的固定配置项,所有人看同一张图看到同一个时间轴,问题才彻底消失。教训是:展示时区应该是业务决策,不是浏览器决策。
第二次是“对数坐标拯救了一张图,也骗了一个人”。某个接口的耗时跨度从 5ms 到 4000ms,线性坐标下低值全挤在底部一条线。我换了对数轴,图立刻好看多了。结果有人拿着图说“这个接口耗时一直在 100ms 以下波动,很稳”——他根本没注意到纵轴是对数的。后来我在对数轴的标题上强制加了“(对数刻度)”字样,并且在图注里写明“每一格代表 10 倍”。这个细节现在已经是我的硬性规范。
第三次是“降采样把一次故障抹平了”。一个 7 天窗口的错误率曲线,用了时间桶均值。某天有一次持续 8 分钟的 100% 错误率,被平均进一个 30 分钟的桶里,显示成 20% 多的小凸起。值班同学看了一眼说“有点抖动,不影响”。后来复盘才发现那是真事故。从那之后,凡是错误率、超时率这类有明确“异常语义”的指标,我一律改用桶内最大值或者 P99 来降采样,宁可图看起来毛糙一点,也不能让异常隐形。
6.2 上线前我会逐条过一遍的清单
这张表是我自己用的,每次交付前花五分钟过一遍,能省掉后面几小时的扯皮。
| 检查项 | 具体做法 | 不做的后果 |
|---|---|---|
| 时间单位 | 校验时间戳位数/量级 | 整条曲线塌陷或错位 |
| 时区 | 统一 UTC 计算,展示层固定时区 | 跨区域看图结论不一致 |
| 排序 | 按时间索引排序后再画 | 线条来回穿越,像信号异常 |
| 缺失 | 区分成因,长缺口留白并标注 | 凭空造出不存在的平稳状态 |
| 聚合口径 | 比率型分子分母分别聚合 | 成功率被系统性低估 |
| 降采样 | 异常类指标用 max/P99,趋势类用 LTTB | 关键尖峰被平均掉 |
| 双轴 | 能用小倍数图就不用双轴 | 制造虚假相关性 |
| 刻度 | 限制刻度数量,跨天带日期 | 标签堆成墨块或产生歧义 |
| 配色 | 不依赖红绿区分,最多 6 类 | 色觉异常者无法阅读 |
| 图注 | 注明单位、聚合方式、降采样方法 | 读者无法判断数据可信度 |
最后一条我想多说两句。图注这个位置,大部分人写“图1 某指标趋势”,等于没写。我的写法是把它当成一个微型的“数据说明书”:“窗口 7 天,原始粒度 1 分钟,按 30 分钟桶取 P99 降采样,时区 UTC+8,缺口超过 3 个采样点处留白”。这么一行字,能让看图的人知道结论的边界在哪里,也省得你反复解释。
6.3 一个反直觉的经验:先画丑图,再谈好看
我见过不少团队在可视化上走的第一条弯路,是先花两天调配色和圆角,结果数据口径推翻了三次,图重做了三遍。我现在的习惯是:第一版一定是最丑的——黑色细线、默认字号、不做任何美化,只保证时间轴对、缺口留白、极值可见。拿这版去和数据源头的人对口径,把“这个峰是真的还是采集抖动”“这段空白是没数据还是值为零”全问清楚。等口径稳定了,再花半天做视觉打磨,效率高得多。
顺带提一个实操上的取舍:如果你不确定对方会不会看交互,就先出静态图。静态图逼着你把该标的信息都标在图上(阈值、事件、缺口),而交互图很容易变成“反正鼠标放上去能看到”的借口。我现在的做法是静态图作为主交付物,交互图作为附加品,这样即使对方只是截图转发,信息也是完整的。
最后再分享一个小技巧:把降采样的策略参数化,别硬编码。写成一个{指标类型: 降采样方式}的映射表,新接入一个指标只需要在表里加一行,而不是去改绘图逻辑。这个改动看起来很小,但在指标数量从 5 个涨到 50 个的过程中,能救你无数个下午。系列的第二篇我会接着讲多序列对齐和实时刷新的部分,那里面坑更多。