流式分位数计算实战:beorn7/perks quantile 包在 docker-ce 中的实现原理与使用 容器运行时云原生【免费下载链接】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:项目地址https://gitcode.com/gh_mirrors/do/docker-ce点击查看免费下载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 SrivastavaATT Labs–Research以及 S. Muthukrishnan罗格斯大学合著的论文Effective Computation of Biased Quantiles over Data StreamsREADME 中 明确致谢 了这四位研究者。该算法属于有偏分位数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 版权声明LICENSEMIT 协议go.mod声明模块github.com/beorn7/perksgo 1.11quantile/stream.go全部核心实现仅依赖标准库math与sort2.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 使用时需要外部加锁或各自维护独立流新流创建时缓冲初始容量为 500make(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 迭代比切片慢得多预先转换能显著节省 CPUresulting in significant CPU savingstargets : 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时用下界分支否则用上界分支最终取所有目标中最严格最小的那个 ƒ 值——这保证了每个声明过的分位点的误差约束都同时成立。四、流式写入与查询 API4.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 时触发一次合并 } }每条观测以Width1进入缓冲只有当缓冲达到容量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.queryfunc (s *stream) query(q float64) float64 { t : math.Ceil(q * s.n) // 目标秩 t math.Ceil(s.ƒ(s, t) / 2) // 加上误差界的一半 // 沿样本链表累加宽度直到 rc.Widthc.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内部也有对应 TODOThe paper doesnt 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.Widthx.Widthx.Delta s.ƒ(s, r) { // 满足不变量把相邻样本合并成一个列表减短 x.Width c.Width s.l[xi] x copy(s.l[i:], s.l[i1:]) 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/quantileSummary 的每个分位流由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 的NewTargetedInsertQuery在底层算出来的观测值经缓冲后逐条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.HistogramVecUpdate(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.goPrometheus 接入点components/cli/vendor/github.com/prometheus/client_golang/prometheus/summary.godaemon 指标注册components/engine/daemon/metrics.gogo-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:项目地址https://gitcode.com/gh_mirrors/do/docker-ce点击查看免费下载相关推荐用 Vega 构建分位数-分位数Q-Q图quantile 变换与理论分位数表达式的完整实战用 Vega 构建分位数 分位数Q Q图quantile 变换与理论分位数表达式的完整实战 本文以 Vega 仓库内置的 quantile quantil数据可视化Presto Quantile Digestqdigest函数完全指南近似分位数计算与数据草图实战Presto Quantile Digestqdigest函数完全指南近似分位数计算与数据草图实战 本指南系统讲解 PrestoTrino 前身体系中的大数据数据库后端在 react-native-windows 中使用 folly::Histogram 统计延迟与数据分布从分桶原理到百分位估算实战在 react native windows 中使用 folly::Histogram 统计延迟与数据分布从分桶原理到百分位估算实战 folly::Histo跨平台前端上一篇3分钟实现Mac NTFS读写Free-NTFS-for-Mac终极免费方案下一篇Mac NTFS读写终极指南免费开源工具Free-NTFS-for-Mac完整教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考