- 容器运行时
- 云原生
【免费下载链接】docker-ce
:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:
perks是 Blake Mizerany 维护的一组 Go 工具包,其核心成员quantile子包在内存与 CPU 都受限的条件下,对无界数据流计算近似分位数(quantile)。在 docker-ce 仓库中,该包以第三方依赖形式被 vendored 进components/cli与components/engine,并经由 Prometheus 客户端库间接服务于 daemon 的指标采集链路。本文以 beorn7/perks 的 README 为骨架,结合仓库内的 stream.go 源码,完整讲解其算法背景、三种构造器、流式 API、底层合并/压缩机制,以及它在 docker-ce 监控体系中的实际落点,读完即可理解并上手这一近似分位数方案。
一、背景:为什么需要"流式"分位数
经典的精确分位数计算(如维护全部样本后排序取 rank)有两个前提:样本总量有限且可以全部保存在内存中。在监控、日志、性能剖析这类场景中,数据往往以"无界流"形态不断涌入——每秒成千上万次请求延迟观测、持续增长的观测值序列——此时把所有样本保留下来既浪费内存也不必要。
quantile包解决的就是这个矛盾。其核心思想源自 Graham Cormode(罗格斯大学)、Flip Korn 与 Divesh Srivastava(AT&T Labs–Research)以及 S. Muthukrishnan(罗格斯大学)合著的论文Effective Computation of Biased Quantiles over Data Streams(README 中 明确致谢 了这四位研究者)。该算法属于"有偏分位数"(biased quantiles)方案:在流上维护一个压缩后的有代表性样本集合,用受控的精度损失换取恒定的低内存与低 CPU 占用,这正是"少量精度换内存/CPU 特性"的典型工程取舍(stream.go 包注释原文:A small amount of accuracy is traded to achieve the above properties)。
数据流: v1, v2, v3, ... ──▶ Stream.Insert ──▶ 内部缓冲 + 摘要压缩 ──▶ Stream.Query(q) 返回近似分位值二、包结构与核心数据结构
在 docker-ce 仓库中,该库位于components/cli/vendor/github.com/beorn7/perks/(components/engine/vendor/github.com/beorn7/perks/下有同构副本),完整结构如下:
- README.md:包介绍、算法论文出处、FAQ 与 MIT 版权声明
- LICENSE:MIT 协议
- go.mod:声明模块
github.com/beorn7/perks,go 1.11 - quantile/stream.go:全部核心实现,仅依赖标准库
math与sort
2.1 Sample:带压缩元信息的观测值
分位数算法不需要记住每个原始值,而是维护"样本 + 压缩元信息"的元组:
type Sample struct { Value float64 `json:",string"` Width float64 `json:",string"` Delta float64 `json:",string"` }Value:观测值本身;Width:该样本在数据流中代表的观测个数(合并后宽度会累加);Delta:用于判断"能否安全压缩掉该样本"的误差界信息。
三个字段都加了json:",string"标签,方便在 JSON 序列化时以字符串形式保留浮点精度。Samples则是对[]Sample的别名封装,并实现了sort.Interface(按Value升序排序,见Len/Less/Swap),这意味着样本切片可以直接交给标准库排序。
2.2 Stream:非线程安全的流式摘要
type Stream struct { *stream // 内部摘要:n(总权重)、l(样本列表)、ƒ(不变量函数) b Samples // 插入缓冲 sorted bool // 缓冲是否已排序 }从源码注释(stream.go)可以明确两点:
Stream设计上不是线程安全的(It is not thread-safe by design),跨多个 goroutine 使用时需要外部加锁或各自维护独立流;- 新流创建时缓冲初始容量为 500(
make(Samples, 0, 500)),这是"分批合并"的触发阈值。
三、三种构造器与各自的误差保证
quantile提供三个工厂函数,对应三类不同的使用前提。它们的差异在于内部"不变量函数 ƒ"的定义,而 ƒ 正是压缩算法决定"哪些样本可以被合并"的依据。
3.1 NewLowBiased:低偏置分位数
适用场景:需要的分位数事先未知,但即使对**数据分布的低位段(小 rank)**也要给出误差保证,典型目标是 0.01、0.1、0.5 这类低分位。
func NewLowBiased(epsilon float64) *Stream { ƒ := func(s *stream, r float64) float64 { return 2 * epsilon * r } return newStream(ƒ) }误差语义:epsilon是相对误差,查询返回值的真实分位数保证落在(1±Epsilon) * Quantile范围内。
3.2 NewHighBiased:高偏置分位数
对称地关注分布高位段(如 99%、99.9% 这类长尾分位,正是延迟监控最关心的):
func NewHighBiased(epsilon float64) *Stream { ƒ := func(s *stream, r float64) float64 { return 2 * epsilon * (s.n - r) } return newStream(ƒ) }这里s.n是当前流的总权重(样本数),r是累计秩;误差保证为真实分位数落在1-(1±Epsilon)*(1-Quantile)范围内——即从右端(1.0 一侧)起算的相对误差。
3.3 NewTargeted:面向已知目标分位数集合
当分位点事先确定时,可以告诉算法"我只要这些 rank",从而进一步压缩空间与计算时间。它的参数是把"目标分位数 → 绝对误差"映射起来:
func NewTargeted(targetMap map[float64]float64) *Stream误差语义:返回值的真实分位数保证落在Quantile ± Epsilon(绝对误差,区别于前两者的相对误差)。
源码中有一个值得注意的性能细节:构造时先把map转成[]target切片(targetMapToSlice)。注释说明原因——Ƒ 会在热路径上被频繁调用,map 迭代比切片慢得多,预先转换能显著节省 CPU(resulting in significant CPU savings):
targets := targetMapToSlice(targetMap) ƒ := func(s *stream, r float64) float64 { var m = math.MaxFloat64 var f float64 for _, t := range targets { if t.quantile*s.n <= r { f = (2 * t.epsilon * r) / t.quantile } else { f = (2 * t.epsilon * (s.n - r)) / (1 - t.quantile) } if f < m { m = f } } return m }对每个目标分位,当秩r已越过t.quantile * s.n时用"下界分支",否则用"上界分支",最终取所有目标中最严格(最小)的那个 ƒ 值——这保证了每个声明过的分位点的误差约束都同时成立。
四、流式写入与查询 API
4.1 Insert:观测值入流
func (s *Stream) Insert(v float64) { s.insert(Sample{Value: v, Width: 1}) } func (s *Stream) insert(sample Sample) { s.b = append(s.b, sample) s.sorted = false if len(s.b) == cap(s.b) { s.flush() // 缓冲满 500 时触发一次合并 } }每条观测以Width=1进入缓冲;只有当缓冲达到容量(500)时才flush到内部摘要并做一次压缩。这种批处理设计让每个样本的平均处理开销远低于"逐条合并"。
4.2 Query:查询 q 分位值
func (s *Stream) Query(q float64) float64 { if !s.flushed() { // 数据量尚未触发过 flush 的快路径:直接返回精确排序结果 l := len(s.b) if l == 0 { return 0 } i := int(math.Ceil(float64(l) * q)) if i > 0 { i -= 1 } s.maybeSort() return s.b[i].Value } s.flush() return s.stream.query(q) }两个分支值得展开:
- 未 flush 的快路径:样本还都完整保存在缓冲中,直接用
ceil(l*q)-1索引取近似值。注释强调这不仅快,而且对小数据集的精度更好(this also yields better accuracy for small sets of data)。 - 已 flush 的慢路径:先强制 flush,再走摘要查询
stream.query:
func (s *stream) query(q float64) float64 { t := math.Ceil(q * s.n) // 目标秩 t += math.Ceil(s.ƒ(s, t) / 2) // 加上误差界的一半 // 沿样本链表累加宽度,直到 r+c.Width+c.Delta > t,返回当前样本的 Value }stream.query沿压缩后的样本列表从前往后累加Width,结合每个样本的Delta判断目标秩落在哪个样本的"管辖区间",从而返回对应的Value。这正是有偏分位数算法中"用宽度与 delta 逼近目标秩"的标准做法。
需要注意一处语义限制:如果用NewTargeted创建流,而查询的q不在预先提供的分位数集合里,Query会返回未定义的结果(an unspecified result)——使用时应只查询声明过的目标分位。
4.3 Count:已观测样本总数
func (s *Stream) Count() int { return len(s.b) + s.stream.count() }缓冲中的条数加上内部摘要累计的总权重(int(s.n)),用于判断"流里到底有没有数据"。
五、合并、重置与样本导出
5.1 Merge 与它的已知缺陷
// ATTENTION: This method is broken and does not yield correct results. // The underlying algorithm is not capable of merging streams correctly. func (s *Stream) Merge(samples Samples) { sort.Sort(samples) s.stream.merge(samples) }源码注释明确警告:Merge目前是坏掉的——底层算法本身并不支持正确合并多个流摘要。它最初的设计意图是把来自不同线程、不同数据库分片的多份样本合并成一份结果,但现有实现合并摘要会产生不准确的结果。因此生产代码不应依赖它做跨流聚合;stream.merge内部也有对应 TODO(The paper doesn't mention merging summaries at all. Unittests show that the merging is inaccurate)。
5.2 Reset:复用缓冲内存的清零
func (s *Stream) Reset() { s.stream.reset() s.b = s.b[:0] }只把切片长度归零、不释放底层数组,从而复用缓冲内存,适合"滚动窗口"式使用(这正是 Prometheus Summary 每过一个时间桶就 Reset 一个流的原因,见下文)。
5.3 Samples:导出当前样本
Samples()在未 flush 时直接返回缓冲(完整样本),已 flush 时先 flush 再返回内部摘要的拷贝(stream.samples做copy,避免外部修改内部状态)。导出样本便于序列化、持久化或离线分析。
六、底层引擎:merge 与 compress 如何保证低内存
Stream之所以能保持内存有界,靠的是内部stream.merge之后的compress():
func (s *stream) compress() { if len(s.l) < 2 { return } x := s.l[len(s.l)-1] xi := len(s.l) - 1 r := s.n - 1 - x.Width for i := len(s.l) - 2; i >= 0; i-- { c := s.l[i] if c.Width+x.Width+x.Delta <= s.ƒ(s, r) { // 满足不变量:把相邻样本合并成一个,列表减短 x.Width += c.Width s.l[xi] = x copy(s.l[i:], s.l[i+1:]) s.l = s.l[:len(s.l)-1] xi -= 1 } else { x = c xi = i } r -= c.Width } }压缩条件是c.Width + x.Width + x.Delta <= ƒ(s, r):当两个相邻样本合并后仍不破坏 ƒ 定义的误差界时,就把它们折叠成一个更宽的样本。不变量函数 ƒ 越大(即允许误差越宽松),能合并掉的样本越多,摘要越紧凑——这就是"误差上限"与"内存占用"之间的直接杠杆。三种构造器本质上是给这把杠杆配上不同的"刻度",对应各自的分位数偏置策略。
七、在 docker-ce 中的实际落点:Prometheus Summary 的分位数引擎
perks/quantile在 docker-ce 中不是被直接 import 的,而是作为 Prometheus Go 客户端(prometheus/client_golang)的底层依赖被 vendored(见components/cli/vendor.conf与components/engine/vendor.conf中的依赖声明)。其接入点非常清晰:
- summary.go 中
import "github.com/beorn7/perks/quantile"; - Summary 的每个分位流由
quantile.NewTargeted(s.objectives)创建(见summary.go的newStream方法); - Summary 用固定数量、按时间桶轮流复用的流数组(
streams []*quantile.Stream、headStream *quantile.Stream)实现"最近 10 分钟"滑动窗口:每过一个streamDuration就Reset并切换到下一个流,配合DefMaxAge = 10 * time.Minute、DefAgeBuckets = 5、DefBufCap = 500三个默认常量。
也就是说,Summary 上报的中位数/90/99 分位延迟估计,就是由 perks/quantile 的NewTargeted+Insert+Query在底层算出来的,观测值经缓冲后逐条Insert进流(flushColdBuf中对每个 stream 调用stream.Insert(v)),抓取指标时对每个目标分位调用headStream.Query(rank)(summary.go的Write方法)。
再向上游看,docker 引擎 daemon 自身的指标体系正是建立在 Prometheus 客户端之上:
- components/engine/daemon/metrics.go 通过
docker/go-metrics(其内部即封装prometheus客户端,见 go-metrics/timer.go)注册了engine_daemon_container_actions、engine_daemon_network_actions、engine_daemon_health_checks等指标; go-metrics的LabeledTimer底层持有prometheus.Observer(*prometheus.HistogramVec),Update(duration)调用Observe(duration.Seconds())。
因此可以这样概括调用链:
docker daemon 动作耗时 ──▶ go-metrics Timer.Update ──▶ prometheus Observer.Observe ──▶ quantile.Stream.Insert(经 Summary 缓冲)──▶ 抓取时 Query(0.5/0.9/0.99) 输出分位估计虽然 daemon 默认的 go-metrics 定时器底层用的是 Histogram 而非 Summary,但perks/quantile作为 vendored 依赖存在于两个组件中,任何基于prometheus.NewSummary的指标(如插件、swarmkit、registry 相关采集)都会直接受益于它的低内存近似分位能力。对读者而言,理解它的 API 与误差语义,是正确解读 Prometheus Summary 指标精度上限的前提。
八、一段可直接运行的完整示例
结合以上 API,一个典型的"流式延迟分位监控"用法如下:
package main import ( "fmt" "math/rand" "time" "github.com/beorn7/perks/quantile" ) func main() { // 关注 50%、90%、99% 三个分位点,各允许 0.05 的绝对误差 s := quantile.NewTargeted(map[float64]float64{ 0.50: 0.05, 0.90: 0.05, 0.99: 0.01, }) rnd := rand.New(rand.NewSource(42)) for i := 0; i < 100_000; i++ { // 模拟请求延迟:均值 100ms、含长尾 latency := 100 + rnd.NormFloat64()*20 + rnd.Float64()*300 if latency < 0 { latency = 0 } s.Insert(latency) time.Sleep(time.Microsecond) // 仅示例,实际无需逐条等待 } fmt.Printf("count = %d\n", s.Count()) fmt.Printf("p50 = %.2f ms\n", s.Query(0.50)) fmt.Printf("p90 = %.2f ms\n", s.Query(0.90)) fmt.Printf("p99 = %.2f ms\n", s.Query(0.99)) fmt.Printf("samples = %d 条(压缩后摘要)\n", len(s.Samples())) }实际运行中注意三点:Insert只在缓冲满 500 条时合并压缩,因此写入与查询可以交错而不会牺牲正确性;如果改用NewLowBiased(0.05)/NewHighBiased(0.05),则无需预先声明分位点,适合"不知道要查哪些 rank"的场景;多 goroutine 共享同一个流时必须自行加锁,或按 goroutine 各建一流后仅做展示级合并(Merge已知不可靠,勿用于精确聚合)。
九、使用边界与工程注意点
综合 README 与源码注释,使用时请留意以下约束:
- 线程安全:
Stream非线程安全,跨 goroutine 需外部同步(stream.go 的类型注释)。 Merge不可用:源码明确标注该方法是 broken 的,跨流/跨分片聚合会产生错误结果。NewTargeted的查询受限:只能查询预先声明的分位数,否则返回未定义值。- 精度是有意换来的:内存与 CPU 的有界性以少量精度为代价,选择
epsilon时要在"内存占用"与"误差容忍度"之间权衡——epsilon越小,保留的样本越多,内存越高。 - 依赖关系定位:在 docker-ce 中它属于 vendored 三方依赖(
vendor.conf声明),并由prometheus/client_golang间接引入;如需深挖 Summary 指标精度,起点是 components/cli/vendor/github.com/prometheus/client_golang/prometheus/summary.go。
十、小结
perks/quantile用约 300 行代码,把"有偏分位数数据流算法"(Cormode et al., 2005)落地为一个低内存、低 CPU、可预测误差的 Go 包:三种构造器分别覆盖"低位优先""高位优先""目标分位集合已知"三类场景;Insert/Query/Count/Reset/Samples构成完整的流式读写闭环;内部的merge + compress与不变量函数 ƒ 是内存有界性的根本保证。在 docker-ce 中,它隐身于 Prometheus Summary 之后,为指标分位估计提供底层引擎——理解它,也就理解了 Docker 监控链路里"P99 从哪来、误差有多大"的答案。
相关文件速览:
- 包说明与致谢:components/cli/vendor/github.com/beorn7/perks/README.md
- 核心实现:components/cli/vendor/github.com/beorn7/perks/quantile/stream.go
- Prometheus 接入点:components/cli/vendor/github.com/prometheus/client_golang/prometheus/summary.go
- daemon 指标注册:components/engine/daemon/metrics.go
- go-metrics 计时器封装:components/engine/vendor/github.com/docker/go-metrics/timer.go
- 容器运行时
- 云原生
【免费下载链接】docker-ce
:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:
相关推荐
用 Vega 构建分位数-分位数(Q-Q)图:quantile 变换与理论分位数表达式的完整实战
用 Vega 构建分位数 分位数(Q Q)图:quantile 变换与理论分位数表达式的完整实战 本文以 Vega 仓库内置的 quantile quantil
数据可视化Presto Quantile Digest(qdigest)函数完全指南:近似分位数计算与数据草图实战
Presto Quantile Digest(qdigest)函数完全指南:近似分位数计算与数据草图实战 本指南系统讲解 Presto(Trino 前身体系中的
大数据数据库后端在 react-native-windows 中使用 folly::Histogram 统计延迟与数据分布:从分桶原理到百分位估算实战
在 react native windows 中使用 folly::Histogram 统计延迟与数据分布:从分桶原理到百分位估算实战 folly::Histo
跨平台前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考