- 容器运行时
- 云原生
【免费下载链接】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:
本文以 Docker CE 仓库中 vendor 的github.com/beorn7/perks包说明文档为骨架展开,完整解读其quantile子包如何以有限的内存和 CPU 代价对无界数据流计算近似分位数(P50、P99 等)。读完本文,你将理解 perks 的三种流构造器(low-biased / high-biased / targeted)各自的误差保证与适用场景、Stream从插入、压缩到查询的完整数据流,以及它如何在 Docker Engine 的dockerd --metrics-addr指标暴露链路中作为 PrometheusSummary的底层支撑被间接使用。
1. perks 是什么:README 的原始定位
仓库中的文档 components/engine/vendor/github.com/beorn7/perks/README.md 对该包的定位非常精炼:
Perks contains the Go package quantile that computes approximate quantiles over an unbounded data stream within low memory and CPU bounds.
也就是说,perks 解决的是这样的经典问题:在一个只进不出的数据流上(日志、请求延迟、监控指标……),如何在内存占用不随数据量增长的前提下,随时回答“当前数据的第 q 分位数大约是多少”。精确分位数要求保留全部样本,内存与数据量成正比;perks 的策略是“用少量精度换取空间与时间的有界性”,这正是流式算法中经典的 biased quantiles 思路。README 还特别致谢了 Graham Cormode、Flip Korn、S. Muthukrishnan 与 Divesh Srivastava 发表在 Rutgers/AT&T Labs 的论文Effective Computation of Biased Quantiles over Data Streams——这份论文正是 stream.go 包注释中声明的算法来源。
README 中还有一个简短的 FAQ:为什么不把quantile包直接放在项目根目录?作者的回答是“后面还想往 perks 里加更多包”。这解释了本仓库 vendor 目录中github.com/beorn7/perks/下只有LICENSE、README.md、go.mod和一个quantile/子目录的扁平结构(go.mod中声明go 1.11)。
在 Docker CE 仓库中,perks 出现在两个组件的 vendor 目录里,且内容完全一致:
- components/engine/vendor/github.com/beorn7/perks(Docker Engine 侧)
- components/cli/vendor/github.com/beorn7/perks(CLI 侧)
两者均不是 Docker 自己直接调用的包,而是Prometheus 客户端库client_golang的 Summary 指标类型的依赖——这一点在 components/engine/vendor/github.com/prometheus/client_golang/prometheus/summary.go 中可以看到:
import ( ... "github.com/beorn7/perks/quantile" )后文第 4 节会沿着这条链路,看它在dockerd中如何被真正驱动。
2. 核心数据结构:Sample 与三个构造器
打开 quantile/stream.go,整个实现围绕一个三元组展开:
// Sample holds an observed value and meta information for compression. JSON // tags have been added for convenience. type Sample struct { Value float64 `json:",string"` Width float64 `json:",string"` Delta float64 `json:",string"` } // Samples represents a slice of samples. It implements sort.Interface. type Samples []Sample这三个字段的含义是理解整个算法的钥匙:
Value:该样本(或一组被合并的样本)代表的数值;Width:这个样本“代表”多少个原始观测。初始插入时Width为 1,压缩时多个相邻样本会被合并,Width累加;Delta:误差预算的“余量”。压缩时只有当合并两个样本不会突破当前排名位置的误差上界(由不变式函数ƒ给出)时才允许合并,Delta记录的是被合并样本在排序插入时允许的额外秩漂移。
Samples实现了sort.Interface(按Value升序排序),这一点在后续flush、merge流程中会反复用到。
包提供了三个流构造器,对应论文中三种“偏置”策略。构造函数签名及注释(stream.go#L39-L102)分别如下:
NewLowBiased:低秩偏置
func NewLowBiased(epsilon float64) *Stream { ƒ := func(s *stream, r float64) float64 { return 2 * epsilon * r } return newStream(ƒ) }注释写明:适合“关心的分位数未知、但希望对分布的低秩部分(如 0.01、0.1、0.5)也给出误差保证”的场景。其误差保证是相对误差:查询返回值的真实分位数保证落在(1±Epsilon)*Quantile区间内。不变式ƒ(s, r) = 2εr意味着允许的秩误差随排名r线性增长——排名越低,被压缩掉的细节越少,低秩处精度越高。
NewHighBiased:高秩偏置
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)。
NewTargeted:指定分位数目标
func NewTargeted(targetMap map[float64]float64) *Stream { // Convert map to slice to avoid slow iterations on a map. // ƒ is called on the hot path, so converting the map to a slice // beforehand results 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 newStream(ƒ) }这是三者中最贴近实际工程用法的一个:事先给出“想要的分位数 → 允许误差”的映射(例如map[0.5]0.01, map[0.99]0.01),返回值的真实分位数保证落在(Quantile±Epsilon)的绝对误差内。源码中有两处工程细节值得注意:
targetMapToSlice先把 map 转成[]target切片,注释解释原因:ƒ位于热路径上,每次调用都遍历 map 会带来显著的 CPU 开销;ƒ对每个目标计算一条“低秩误差线”与一条“高秩误差线”,取所有目标中的最小值m作为该排名位置的秩误差上界——即流的整体精度由所有目标中最严格的那条约束决定。这正是 PrometheusSummary的Objectives map[float64]float64(“q → 绝对误差”)能够映射到 perks 的原因,见 summary.go#L115-L120。
三个构造器最终都收敛到同一个工厂:
func newStream(ƒ invariant) *Stream { x := &stream{ƒ: ƒ} return &Stream{x, make(Samples, 0, 500), true} }注意这里预分配了容量为500的样本缓冲区b——这个 500 在 client_golang 中被原样引用:SummaryOpts.BufCap的注释明确建议“如需调整,请以 500 的倍数设置,因为底层perks/quantile的默认内部缓冲区大小就是 500”(summary.go#L135-L140)。
3. 数据流生命周期:Insert → 缓冲 → flush → 压缩 → Query
Stream的公开结构(stream.go#L123-L134)是“缓冲区 + 已压缩摘要”的两级结构:
// Stream computes quantiles for a stream of float64s. It is not thread-safe by // design. Take care when using across multiple goroutines. type Stream struct { *stream b Samples sorted bool }文档同时强调Stream并非线程安全,跨 goroutine 使用时需要自行加锁——client_golang 的summary内部就是用sync.Mutex保护多个*quantile.Stream的(见 summary.go#L265-L267 中的streams []*quantile.Stream字段)。
3.1 插入路径
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() } }Insert只做三件事:追加Width:1的样本、把sorted标记置为 false、检查缓冲区是否达到容量(500)后触发flush。也就是说,压缩(compress)不是每次插入都执行,而是批量进行,这是 CPU 开销有界的关键。
3.2 flush 与两级摘要
func (s *Stream) flush() { s.maybeSort() s.stream.merge(s.b) s.b = s.b[:0] }flush先把缓冲区b排序,再整体交给内层stream.merge,随后复用缓冲区内存。内层merge是插入排序式的合并(stream.go#L234-L266):对每个待插入样本,沿已有摘要列表s.l找到首个更大的样本位置插入;插入点的Delta取
math.Max(sample.Delta, math.Floor(s.ƒ(s, r))-1)即由不变式函数在该排名处给出的秩误差上界决定。源码作者在merge中留下了一段坦率的 TODO 注释:
// TODO(beorn7): This tries to merge not only individual samples, but // whole summaries. The paper doesn't mention merging summaries at // all. Unittests show that the merging is inaccurate. Find out how to // do merges properly.这一点对理解公开的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) { ... }即跨流合并(如把多个分片各自维护的摘要合并成一个)在算法上并不正确。client_golang 的 summary 实现中有一大段注释直接引用了这个问题:为避免调用Merge,Prometheus 的滑动窗口(age buckets)设计改为“每次观测都写入每一个 age bucket”,轮换时直接弃用旧 head stream,抓取时只取 head stream 的分位数——“在观测侧多付出代价,换取抓取侧的轻量且结果正确”(summary.go#L143-L156)。阅读 perks 源码时,这是一个必须知道的边界:单流内近似没问题,跨流Merge的结果不可信。
3.3 压缩:让内存与数据量解耦
merge的末尾统一调用compress(stream.go#L287-L310):
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 // Remove element at i. 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、x,若把它们合并不会突破排名r处的秩误差上界s.ƒ(s, r),就把c.Width累加进x.Width并删除c;否则以c作为新的合并起点。由于ƒ的形状由构造器决定(线性或分段线性),摘要长度被限制在与1/ε相关的量级内,而与观测总数n无关——这就是 README 中“low memory and CPU bounds”的实现本质:内存只存摘要,不存原始样本。
3.4 查询路径与快路径
func (s *Stream) Query(q float64) float64 { if !s.flushed() { // Fast path when there hasn't been enough data for a flush; // this also yields better accuracy for small sets of data. 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) }Query有两条路径:
- 快路径:如果内层摘要还是空的(数据量不足以触发一次 flush),直接把缓冲区排序后取第
ceil(l*q)个元素的值——对少量数据这等价于精确分位数,注释也说明这“对小数据集还能带来更好的精度”; - 常规路径:先
flush再调用内层query。内层查询按目标秩t = ceil(q*n) + ceil(ƒ(s,t)/2)扫描摘要,返回第一个满足r + c.Width + c.Delta > t的前驱样本值(stream.go#L272-L285)。
另外还有三个维护性 API:Samples()返回当前摘要样本的副本;Count()返回自初始化以来观测的总样本数(缓冲区未 flush 部分 + 已压缩部分);Reset()清空流并复用缓冲区内存,便于周期性重置(例如按时间窗重置分位数统计)。
4. 在 Docker Engine 中的真实位置:Prometheus Summary 的底层
perks 的 README 本身不涉及 Docker 的集成,但仓库中的 vendor 依赖链把它和dockerd的指标暴露直接串了起来。这条链路全部可以在当前仓库中验证:
dockerd注册--metrics-addr标志:cmd/dockerd/config.goflags.StringVar(&conf.MetricsAddress, "metrics-addr", "", "Set default address and port to serve the metrics api on")启动时拉起指标 HTTP 服务:cmd/dockerd/daemon.go 在 daemon 启动流程中调用
startMetricsServer(cli.Config.MetricsAddress);cmd/dockerd/metrics.go 把路由挂到/metrics:mux.Handle("/metrics", metrics.Handler())metrics.Handler()返回 promhttp 全局收集器:vendor/github.com/docker/go-metrics/handler.gofunc Handler() http.Handler { return promhttp.Handler() }daemon 自身的指标是 Counter/Gauge/Timer 为主:daemon/metrics.go 中通过
go-metrics注册了engine_container_actions(容器动作耗时 Timer)、engine_network_actions、engine_cpus、engine_memory、engine_health_checks等指标。
也就是说,引擎自带指标以直方图桶式(Timer)与计数器/仪表为主;而prometheus.NewSummary/NewSummaryVec这类会构造quantile.NewTargeted(s.objectives)流(summary.go#L329-L330)的指标类型,是任何基于client_golang对 Docker 做二次埋点或插件化指标采集时的标准选择。summary.newStream()返回的正是第 2 节中的NewTargeted流——Objectives里的每个q → 绝对误差对原样作为targetMap传入,BufCap(默认 500 的倍数)则控制每个 age bucket 内缓冲区的容量。
从源码结构看,perks 在 Docker CE 中的角色可以概括为一句话:它不是引擎代码直接 import 的功能库,而是client_golangSummary 类型的引擎。凡是走promhttp.Handler()暴露的 Summary 指标,其分位数估算最终都会落到quantile.Stream的Insert/Query/compress上。这也解释了 vendor 目录中为什么perks与client_golang必须同时存在。
5. 使用要点与已知限制小结
结合 README、stream.go 与 client_golang 的集成代码,可以归纳出如下使用要点(均以当前仓库代码为准):
| 要点 | 依据 |
|---|---|
三种构造器按需选择:目标分位数已知时用NewTargeted(绝对误差),只关心低秩/高秩时用NewLowBiased/NewHighBiased(相对误差) | stream.go#L39-L102 |
NewTargeted的targetMap为“分位数 → 绝对误差”;epsilon越小的构造器内存越大 | stream.go#L73-L80 |
缓冲区容量 500 是内部默认值,flush 前Query走精确排序快路径 | stream.go#L131-L169 |
Stream非线程安全,多 goroutine 需外部加锁;多分片场景下可用Reset周期性重建 | stream.go#L123-L129、stream.go#L181-L185 |
公开的Merge方法被源码明确标注“broken”,跨流合并不可用于生产;client_golang 的 age-bucket 滑动窗口即为绕开它而设计 | stream.go#L171-L179、summary.go#L143-L156 |
在 Docker Engine 中的实际入口是dockerd --metrics-addr→/metrics→promhttp.Handler();Summary 类指标的分位数由quantile.NewTargeted流计算 | config.go#L89、metrics.go#L24、summary.go#L329-L330 |
需要说明的适用前提:以上内容基于当前 docker-ce 仓库(该仓库整体已标记 deprecated 并计划归档,Docker CE 项目本身并未停止)中 engine 组件的 vendor 快照,perks 的模块声明为go 1.11,stream.go中没有版本守卫或可选特性,行为以这份源码为准。如果你要在自己的 exporter 或 dockerd 插件中直接引用quantile包,建议遵循第 5 节的“单流使用 + 外部加锁 + 避免Merge”的组合方式,这正是当前仓库内唯一经过完整验证的用法(client_golang Summary)。
- 容器运行时
- 云原生
【免费下载链接】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:
相关推荐
cuDF 分位数计算指南:pylibcudf.quantiles 模块的 quantile / quantiles API 详解
cuDF 分位数计算指南:pylibcudf.quantiles 模块的 quantile / quantiles API 详解 导读 分位数(quantile
数据分析数据工程机器学习D3.js Quantile Scale 详解:scaleQuantile 的 API、分位数原理与源码机制
D3.js Quantile Scale 详解:scaleQuantile 的 API、分位数原理与源码机制 在数据可视化中,当需要把连续的数值型数据(如体重、
前端数据可视化图表库Kode Agent终极指南:后人类工作流设计的AI编程革命
Kode Agent终极指南:后人类工作流设计的AI编程革命 Kode Agent是一款革命性的AI编程助手,专为后人类工作流设计,通过智能终端交互和全栈自动化
AI 应用开发工具AI AgentCLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考