System designDeep dive10 min

Percentile sketches

A p99 needs every value sorted, and the p99s of 300 servers can't be averaged into the fleet's. Percentile sketches summarize a stream of values in a few kilobytes, merge across servers and minutes, and still answer any percentile. How they are built decides whether the answer is close in value or only close in rank, and in a long tail that difference is large.

1 · The problem

A percentile needs every value, and per-server percentiles don't combine

A food-ordering service runs its checkout API on 300 servers. Its target is that 99% of checkouts finish within 1 second, so the dashboard needs the fleet's p99, per minute, per region and per day. In one hour the fleet handles a million checkouts.

The exact p99 means keeping all million durations, sorting them, and reading the value 99% of the way up. Doing that for every minute, region and endpoint is expensive, and each server's own p99 is no shortcut: percentiles can't be averaged or added, because the fleet's slowest 1% may all sit on a few servers (Latency). What the service needs is a summary that is:

Property

Why it matters here

Small and fixed in size

One per server per minute, for 300 servers

Mergeable

Server summaries combine into the fleet's; minutes combine into an hour

Accurate where it counts

The p99 and p99.9 decide alerts; the median rarely does

Any percentile, chosen later

Today's question is p99; next week's may be p99.9

Such a summary is a quantile sketch. Four designs are common, and the first is one most teams already use without calling it a sketch: the histogram.

2 · Fixed buckets

A histogram with fixed buckets merges perfectly, but is only as precise as its buckets

A histogram counts how many values fall in each of a set of ranges, its buckets. Prometheus's default buckets for request durations are 5, 10, 25, 50, 100, 250 and 500 ms, then 1, 2.5, 5 and 10 seconds. Merging is exact: add the counts bucket by bucket, across servers or minutes.

Reading a percentile is not exact. The histogram knows the p99 is in, say, the 500 ms to 1 s bucket, but not where. Prometheus's histogram_quantile assumes the values are spread evenly inside the bucket and interpolates. On a simulated hour of a million checkouts, the true p99 was 805 ms; the default buckets reported 863 ms, 7% high, and the p99.9 came out 2,409 ms against a true 2,158 ms.

Coarse buckets can mislead badly when values cluster. Suppose almost every request takes about 220 ms, there are bucket boundaries at 200 and 300 ms, and the target is a p95 under 300 ms. Interpolation puts the p95 at 200 + 0.95 × 100 = 295 ms, apparently on the edge of breaking the target, when it is really 220 ms.

True p95
220 ms
Interpolated p95
295 ms
Target
300 ms

Every request sits in the 200 to 300 ms bucket, so interpolation can only guess where. The estimate says the service is 5 ms from its target; it is really 80 ms away.

Fixed buckets work well when you choose the boundaries for the question:

  • Put a boundary exactly at each target, here 1 second. The share of checkouts over 1 s is then exact, and an alert can fire on it directly.
  • Add boundaries where the interesting values live, dense around the target, sparse elsewhere.
  • Never change boundaries casually: histograms with different boundaries can't be added.

3 · Two errors

An error in rank can be a huge error in value, out in the tail

Sketches promise one of two kinds of accuracy, and the difference matters most exactly where the dashboard looks:

  • Rank error: the answer to "what is the p99?" is a value whose rank is within ±ε of the 99th percentile. With ε = 0.5%, any value between the p98.5 and the p99.5 is a correct answer.
  • Relative error: the answer is within ±α of the true p99's value. With α = 1%, it is within 1% of 805 ms.

In the middle of a distribution, values are packed close together, so a small rank error is a small value error. In the tail they spread out. In the simulated hour:

True p99
805 ms
1% relative error
811 ms
Fixed default buckets
863 ms
0.5% rank error, low end
594 ms
0.5% rank error, high end
1,185 ms

A sketch with 0.5% rank error may legally report anything from 594 ms (the p98.5) to 1,185 ms (the p99.5), either side of the 1-second target. A 1% relative-error sketch reported 811 ms.

Latency has a long tail, because a few requests hit a slow dependency, a retry or a garbage-collection pause. That is why sketches built for monitoring tend to promise relative error: the value at p99 is what alerts and targets compare against, so the value is what must be right.

4 · Log buckets

Buckets that grow by a fixed ratio give every value the same relative error

The fix for coarse buckets is buckets whose width grows with the value: 2 ms wide near 100 ms, 16 ms wide near 800 ms. If each bucket's upper edge is a fixed ratio γ times its lower edge, every value lands in a bucket about the same share of itself wide, 2% for a 1% error. This is the idea behind DDSketch:

Checkout: 812 msBucket ⌈log_γ 812⌉γ = 1.0202: bucket 335Bucket 335796.5 to 812.6 ms: +1Reported as 804.5 mswithin 1% of any valuewhen it holds the p99

For a relative error α, γ = (1 + α) ÷ (1 − α); for α = 1%, γ ≈ 1.0202. Bucket i reports 2γⁱ ÷ (γ + 1), a point within α of every value the bucket can hold.

The arithmetic stays small. With α = 1%, covering everything from 1 ms to 60 seconds takes ⌈ln(60,000) ÷ ln(1.0202)⌉ = 551 buckets, and only buckets that received values need storing: the simulated hour used 311. The sketch's p99 was 811 ms, 0.7% from the truth, and its p50 and p99.9 were within 0.3%.

Merging is the same as for any histogram, because every sketch with the same γ has the same bucket edges: add the counts. Splitting the million checkouts across 300 simulated servers, sketching each and adding the sketches gave exactly the sketch of all of them. If a sketch must be capped in size, DDSketch merges its lowest buckets first, so the high percentiles keep their accuracy and only the very low end gets blurrier.

Log-bucket design

Precision setting

Example

DDSketch

Relative error α

α = 1%: buckets 2% wide

HdrHistogram

Significant digits

3 digits: within 0.1%, 1 µs to 1 hour

Prometheus native, OpenTelemetry exponential histograms

Growth factor 2^(2^−n) per bucket

Often capped at 160 buckets

HdrHistogram's footprint is fixed by its range and precision, never by the number of values, and recording a value took 3 to 6 nanoseconds even on 2012-era CPUs. Prometheus's native histograms bring the same idea to monitoring: no bucket boundaries to choose, and histograms using the standard schemas can always be merged.

5 · Other sketches

t-digest and KLL trade fixed buckets for clusters and samples

Two other families don't use fixed buckets at all. t-digest keeps a small set of centroids, each a mean value and a count. It deliberately keeps the centroids near the extremes small, so accuracy is relative to how close the percentile is to 0 or 100: p99.9 is resolved much more finely than p50. Its errors are a few parts per million at q ≤ 0.001 and q ≥ 0.999 while keeping only about 50 to 60 centroids (compression 100), and separately built digests can be combined.

KLL keeps a carefully thinned sample of the values. It guarantees rank error: every answer's rank is within ε × n of the requested one. Its size is asymptotically optimal for that guarantee, and it is mergeable. BigQuery's KLL functions use it.

Sketch

Guarantee

Merging

Good fit

Fixed-bucket histogram

None beyond bucket width

Exact, same boundaries

Thresholds you know in advance

DDSketch, HdrHistogram, native histograms

Relative error in value

Exact, same γ or schema

Latency monitoring

t-digest

Strongest near the extremes

Supported

Extreme tails, p99.9 and beyond

KLL

Rank error ε × n

Supported

Any distribution, warehouses

For latency, the long tail favors value accuracy, so the checkout service picks log buckets. For a warehouse query such as "the median order value per city", where values are bounded and the middle matters, a rank guarantee is fine.

6 · In practice

Store sketches per server and per minute, and compute percentiles last

The rule that makes all of this work: keep sketches, not percentiles. Each server records a sketch per minute and sends it on. The monitoring system adds sketches into whatever the dashboard asks for, a region, an hour, the whole fleet, and only then reads off the percentile. A stored p99 is a dead end; a stored sketch can still answer next week's question about p99.9.

  1. Record

    Each server adds every checkout duration to its sketch for the current minute.

  2. Ship

    At the end of the minute it sends the sketch, a few kilobytes, and starts a new one.

  3. Merge

    The monitoring system adds the 300 sketches into the fleet's minute, and minutes into hours.

  4. Read

    Dashboards read p50, p99 and p99.9 from the merged sketch; the alert reads the share of checkouts over 1 second.

For the checkout service, the checklist:

  • Never average or add percentiles; merge sketches, then compute.
  • Prefer relative-error sketches for latency, such as native or exponential histograms or DDSketch.
  • With fixed buckets, put a boundary at every target, and alert on the share above it.
  • Use the same configuration everywhere: same buckets, γ or schema, or the sketches won't merge.
  • Know the guarantee: rank error looks precise and can be far off in the tail.

Check yourself

4 questions

1. Each of 300 servers reports its own p99. How should the dashboard get the fleet's p99?
2. A sketch promises 0.5% rank error. The true p99 is 805 ms. What range of answers is it allowed to give for p99?
3. Why do DDSketch's buckets grow by a fixed ratio γ instead of having a fixed width?
4. The target is "99% of checkouts within 1 second" and the histogram uses fixed buckets. What is the most useful boundary to have?

Takeaways

Remember this

  • Percentiles can't be averaged or added, so store mergeable sketches per server and per minute, and compute percentiles last.
  • Fixed-bucket histograms merge exactly but are only as precise as their buckets; put a boundary at every target.
  • Rank error bounds position, not value; in a long latency tail a small rank error can be hundreds of milliseconds.
  • Log buckets growing by γ = (1 + α) ÷ (1 − α) give every value relative error α, and merge by adding counts.
  • t-digest is strongest at the extremes and KLL gives optimal rank guarantees; pick by which error your question needs.