专业书籍精读 · System Design Interview 卷二 · 第 5 章
System Design Interview Vol 2 · Ch 5 · Alex Xu & Sahn Lam · 2022
公司墙上那块大屏,密密麻麻全是曲线:机器烫不烫、下单慢没慢。半夜手机突然响,说某台服务器不行了——这一章讲的就是那些曲线怎么被收集起来,以及半夜决定该不该把你叫醒的那套东西,怎么从头造出来。
公司的核心存储系统出故障了,你想打开监控看看哪儿出了问题——结果发现监控系统本身也打不开了,因为它的数据正好就存在那套刚挂掉的存储里。
这就是监控系统的第一条铁律:报警器不能接着火房子里的电。它必须比它盯着的一切都更结实、更独立。Google 自家的监控系统为了躲开这个死循环,宁可把数据全放进内存——贵得离谱,但值。
一台机器出问题,你登上去看一眼就知道。一万台呢?而且这些数字一秒不停地往外冒:每台一百个指标、每十秒报一次,加起来每秒一百万个新数字,日夜不休。
更麻烦的是这活儿形状很怪:写进来的量大得吓人、而且雷打不动;看的人却很少——大屏没人盯着的时候它照写。普通数据库按「读写差不多」设计,扔进这种场景会被活活压垮。
一、怎么收。要么每台机器门口挂块小黑板写上当前的数,巡逻员定点挨家挨户去抄(谁家门没开,顺手就知道这家出事了);要么每台机器自己派个小跟班,主动把纸条送到收发室(躲在防火墙后面的、或活不了几分钟的临时任务,就只能这样)。
二、怎么存。接连报上来的数其实长得特别像:时间一秒一秒匀着走,数值常常一动不动。那就别抄全文,只记「跟上一条差多少」——像记账不写「余额 10032、10032、10033」,只写「不变、不变、加一」。同一份数据能瘦掉十倍不止。
三、老数据怎么办。上周三下午三点零七分的 CPU 到底多少,一年后没人关心。所以近几天留全本,超过一个月的压成每分钟一个摘要,再久的压成每小时一个,像把日记逐渐缩写成年终总结。代价很实在:那个一闪而过的尖峰,被摘要抹平之后就永远找不回来了。
四、什么时候叫醒你。一个机房掉线,一千台机器同时喊「我不行了」。每条都发一次,你的手机会在三十秒里响一千次——然后你把它静音了,下次真出事也听不见。所以叫醒你之前,得先把同一件事的一千声喊叫拢成一条。
监控系统不是「一个看图表的网站」,是一条从一百万台设备通到你手机上的流水线:前半截拼的是怎么把海量数字又快又省地咽下去,后半截拼的是怎么在真出事时准确吵醒一个人、又不在没事时烦他。
监控系统是个写得多、读得少的怪系统:靠「只记变化量」把数据压小、靠「老数据缩写成摘要」把成本压住、靠「把一千声重复的喊叫拢成一条」把人保护住。还有一条铁律——它绝不能依赖它自己正在盯着的那套系统。
想进到具体机制、量级和示意图? → 切到精读版
你以为监控系统是个「读系统」——不就是看图表吗?其实它是全公司写入压力最恒定的系统之一:每秒一百万次写入、一年不停,而读只是零星几阵。这一章把「设计一个监控告警系统」拆成一条流水线——采集、传输、存储、查询、告警、可视化——每一节的取舍都被同一条约束驱动:写量恒定得吓人、读量稀疏而突发,且这套系统必须比它监控的一切都更独立。
host=i-001,用来事后切片与聚合。卷二第 5 章,属于「造轮子的内部构造」这条线——和第 4 章是亲戚:那章造 Kafka,这章造 Prometheus + Grafana + Alertmanager 那一整套;往后第 6 章(广告点击聚合)会把这里的「流式预聚合」推得更深。现实里它对应任何一家公司的可观测性平台:Prometheus、Datadog、Google 的 Monarch、Uber 的 M3。
先把题目量化,因为这章几乎所有设计决定都被量级逼出来。书里的场景:一家 1 亿日活(DAU)公司的基础设施——约 1000 个服务器池、每池 100 台机器、每台采 100 个指标,合计约 1000 万条时间序列,采集间隔 10 秒。于是:
10,000,000 ÷ 10 秒 = 1,000,000 次写入 / 秒
每秒一百万个数据点,不是峰值、是常态——凌晨三点也是这个数,且要保留一年。读侧呢?大屏上几十块面板、告警规则每分钟跑一遍,读量比写量小几个数量级,却是突发的(出事时所有人同时翻历史)。
不解决会怎样?用普通关系库硬扛:一行一个数据点,一年就是 3×10¹³ 行量级,索引比数据还大,磁盘和账单先崩。更糟的是第二种失败:系统撑住了,但告警配得稀烂——一次机房抖动刷出上千条通知,值班工程师三个月后已经条件反射地划掉所有告警。这一章要同时解决咽得下和说人话。
监控数据的模型朴素到不像数据库:指标名(metric name)+ 一组标签 + 一串 (时刻, 数值)。比如
cpu.load {host=i-001, env=prod, region=us-west}
关键在于:指标名和标签的每一种组合,就是一条独立的时间序列。标签是这个模型的全部威力所在——你事先并不知道出事时想怎么切,标签让你在查询时才决定「按区域看」还是「按机型看」,不必预先建好每一种汇总表。
代价也正在这儿:基数是各标签取值数的乘积。1000 台机器 × 5 个环境 × 20 个指标 = 10 万条线,还好;换成一个随用户量增长的标签,条数就没边了(§7 详述)。标签只放取值有限、且你真的会拿来分组的维度。
拉取(pull):每个被监控进程开一个 /metrics HTTP 端点,把当前各项数值以文本吐出来,采集器按固定间隔挨个去取。这是 Prometheus 的做法,白送两个好处:调试极其容易(浏览器打开那个端点就看到原始数字),健康检查是顺带的——拉不到就说明实例不在了,不必另做心跳。
它必须回答一个问题:到底该去拉谁?机器天天在扩缩容,名单不能写死。答案是接一个服务发现组件(etcd / ZooKeeper / Consul)周期性地问「现在活着的实例有哪些」;目标多到一个采集器拉不完时,把采集器做成集群,用一致性哈希分片,各管环上的一段,扩容时只搬一小部分。
推送(push):每台机器上跑一个采集代理(agent)主动把数据送来,还能先在本机做一轮预聚合。它赢在拉取够不着的三处:对象藏在防火墙 / NAT 后面、短命任务(跑 30 秒就退出的批作业,等采集器来拉它已经没了——Prometheus 为此专门有个 Pushgateway),以及对上报延迟敏感的场合。
把每个数据点当一行存进关系库理论上能跑,实际在每秒百万写入下需要极深的调优功力。专用 TSDB 赢在两件事,而这两件事都是在利用监控数据本身的形状。
第一件:压缩。监控数据有两个白送的规律——采集是定时的,相邻时间戳的间隔基本恒定;数值是缓变的,磁盘使用率这一秒常和上一秒一模一样。于是只存差值:时间戳记「这次比上次晚多久」,再记「这个间隔比上个间隔差多少」(delta-of-delta,定时采集下几乎恒为 0,一个 bit 就够);数值把相邻两个浮点数按位异或,不变时全是 0、微变时只有少数几位非 0,只传那几位。这能把 16 字节的数据点压到平均 1.37 字节(出处见 §6)。
第二件:分层保留。数据价值随时间塌陷得极快,于是按时间分档:
表 1 · 保留与降采样的分层(书中给出的策略,存储量为按 1000 万条线、10 秒间隔估算的量级)
| 时段 | 粒度 | 用途 | 相对数据点数 |
|---|---|---|---|
| 最近 7 天 | 原始 10 秒 | 排障:要看清尖峰和毛刺 | 基准 1× |
| 7–30 天 | 聚合到 1 分钟 | 周同比、容量趋势 | 1/6 |
| 30 天–1 年 | 聚合到 1 小时 | 季度容量规划、成本核算 | 1/360 |
| 更久 | 冷存储 / 归档 | 合规留存,查得慢也认 | 单价降一个量级 |
两招叠起来效果是乘的。一个具体锚点——书里引的 InfluxDB 实测,8 核 / 32GB 内存的单节点可承受 每秒 25 万次写入;照此推算,扛住那 100 万/秒的常态写入要 4 个这样的节点起步,加上副本与余量,这就是真实的集群规模。
采集器直连 TSDB 能跑,但有一处致命:TSDB 一抖动,采集器就开始丢数据——而监控数据最该被保住的时刻,恰恰就是系统出问题的时刻。标准解法是中间插一条队列(Kafka):采集器只管往队列里写,消费者从队列读、聚合、落 TSDB。队列按指标名分区,保证同一指标落到同一个消费者手里,聚合才算得对;TSDB 挂掉时数据堆在队列里而不是丢掉,恢复后追平即可。
骨架很简单:规则配置 → 告警管理器 → 通知队列 → 发送工作进程。规则是声明式配置,本质是三件事:查什么、什么条件算异常、异常持续多久才算数。Prometheus 风格的写法大致是
expr: up == 0 for: 5m severity: page
那个 for: 5m 是全章最被低估的一行:异常得持续 5 分钟才触发,用一点延迟换掉了绝大多数抖动假警报。真正的工程量则在告警管理器上,它干的全是「保护人」的活:
up == 0,应该合成「机架 A 上 100 个实例不可达」一条。至于可视化,本章态度很干脆:别自己写,Grafana 已经能直连 Prometheus、InfluxDB。同理那个「查询服务」也是可选的——主流 TSDB 自带查询语言(PromQL、InfluxQL、Flux),面板和告警管理器直接连即可;加这层只为缓存热查询、统一鉴权,不是必需品。
表 2 · 采集方向:拉 vs 推(书中对比,逐条标出代价)
| 维度 | 拉取 pull | 推送 push |
|---|---|---|
| 调试 | 胜:浏览器打开 /metrics 就看到原始数字 | 得翻代理日志才知道送了什么 |
| 健康检查 | 胜:拉不到即判定实例已死,白送 | 没数据时分不清「死了」还是「代理没装」 |
| 短命任务 | 输:30 秒的批作业会整个错过抄表窗口 | 胜:跑完就送(需 Pushgateway 补位) |
| 防火墙 / NAT | 输:采集器必须能主动连上每个目标 | 胜:目标只需能对外连一个地址 |
| 目标名单 | 必须接服务发现,多一个依赖 | 胜:谁来算谁,不用名单 |
| 上报延迟 | 受抄表间隔约束(如 10 秒一轮) | 胜:事件发生即送 |
| 防垃圾数据 | 胜:拉谁由服务端说了算 | 输:任何进程都能灌,易被测试任务打爆 |
结论不是「拉更好」——Prometheus 官方自己的说法最诚实(见 §6)。大型平台通常两条都留:常驻服务走拉取,短命任务与边缘设备走推送。
表 3 · 聚合放在哪一层(同一个「区域平均 CPU」,三个位置算,结果一样、代价完全不同)
| 聚合位置 | 省下什么 | 失去什么 | 什么时候选 |
|---|---|---|---|
| 采集端 (agent 本机) | 带宽最省,源头就把点数压下去 | 只做得了本机内的简单聚合,跨机的算不了 | 单机指标极多、出口带宽吃紧 |
| 管道中 (流式预聚合) | 写入 TSDB 的量级性下降,常压到 1/10 以下 | 要引入流处理(Flink 之类);原始点丢了就回不来;迟到数据要专门处理 | 写入已成瓶颈、且聚合维度基本固定 |
| 查询端 (读时算) | 最灵活,事后想怎么切就怎么切,原始数据全留 | 存储与查询成本最高,大范围查询可能几十秒 | 数据量还扛得住,或排障要看清原始毛刺 |
成熟平台多半混合:热门且固定的聚合在管道里预先算好,其余留原始数据到查询端现算——和存储侧「近期留全本、远期留摘要」是同一思路的两次应用。
这一章的分量在于:它是唯一一个所有其他系统都依赖的系统,而它出问题的那一刻,正好是你最需要它的那一刻。这条约束会反过来重塑它的架构——下面第二条实证是最锋利的例子。
大厂实证
16 字节压到平均 1.37 字节;论文自述这使存储占用降到约 1/10、数据得以整体放进内存,相比原先 HBase 支撑的时序存储查询延迟降低 73 倍、吞吐提升 14 倍。本章的压缩与「热数据在内存」正源于此。Pelkonen et al., Gorilla: A Fast, Scalable, In-Memory Time Series Database, VLDB 20155 亿个指标、最终每秒持久化 2000 万个,区域内以 quorum 写三副本;工程师可为不同指标配不同保留期(两天到五年)与粒度(一秒到十分钟)。这正是「分层保留 + 管道预聚合」在真实规模下的样子。Uber 工程博客,M3: Uber's Open Source, Large-scale Metrics Platform for Prometheus(2018)把高基数字段塞进标签。最常见的一次性事故:给指标加上 user_id 或 request_id 标签,时间序列从百万级瞬间涨到十亿级,TSDB 的内存索引被撑爆——而这时你恰恰失去了排查它的工具。判据很简单:一个标签的取值数量不该随用户量或请求量增长。本章对此点到为止,它却是这套系统真正的脆弱点。
以为降采样是无损的。它不可逆,而且坑得隐蔽:百分位不可加——没法从一堆「每分钟平均值」重算出当天的 p99。半年后复盘老事故,只剩小时粒度的平均值,当时打穿 SLO 的那个三分钟尖峰已被抹平成一条直线。要么降采样时就连同分位数与最大值一起预聚合保留,要么接受「过了某个时间点就查不清细节」。这是个成本决策,不是技术细节。
基于原因告警,而不是基于症状。给每个组件都配阈值告警,一次故障刷屏几百条,真正该看的「用户下不了单」反被淹没。Google SRE 的经典主张是对症状告警、把原因留给排查——本章把重心放在去重合并上,方向一致,但没展开告警哲学本身。
时代局限。本章写于 2022 年,是指标中心的视角,开篇就把日志与链路追踪划出范围。而「高基数、宽事件」一派主张:与其预先定义指标,不如保留带丰富维度的原始事件、查询时再切——因为「先降维成指标、再降采样」恰恰丢掉了排查新问题最需要的信息。两条路各有代价:本章这条省钱且可预测,那条贵但答得了你没提前想到的问题。
① 它是个写系统:1000 池 × 100 机 × 100 指标 ÷ 10 秒 = 每秒 100 万次写入的恒定负载,读量小几个数量级但突发——所有设计都由这个不对称驱动。
② 数据模型:一条时间线 = 指标名 + 一组标签;标签给你事后任意切片的自由,代价是基数按乘积增长——塞进高基数字段就是事故。
③ 采集两个方向:拉取白送健康检查、易调试,但要接服务发现(并用一致性哈希给采集器集群分目标);推送搞得定防火墙后与短命任务,代价是「没数据」变得无法解释。成熟平台两条都留。
④ 存储靠两招:差值 + 异或压缩(16 字节 → 1.37 字节量级),再按「7 天原始 / 30 天每分钟 / 1 年每小时」分层降采样——效果相乘。
⑤ 中间插队列(Kafka 按指标名分区):削峰、解耦,TSDB 故障时数据堆着而不是丢掉——监控数据最该被保住的时刻正是出故障的时刻。
⑥ 聚合有三个位置(采集端 / 管道 / 查询端):越早算越省,越晚算越灵活,且管道里丢掉的原始点再也回不来。
⑦ 告警管理器的全部价值是保护人:去重、按根因分组、抑制下游、至少一次投递;for: 5m 这类持续时长条件挡掉绝大多数抖动误报。
⑧ 铁律:监控系统不能依赖它监控的对象。Google 的 Monarch 宁可把数据全放内存、并做区域化隔离,就是为了断开这条循环依赖。可视化与查询层则相反——Grafana 直连 TSDB 已经够用,别自己造。