com.netflix.hystrix.util.HystrixRollingPercentile Maven / Gradle / Ivy
Show all versions of hystrix-core Show documentation
/**
* Copyright 2012 Netflix, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.netflix.hystrix.util;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.concurrent.locks.ReentrantLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.netflix.hystrix.strategy.properties.HystrixProperty;
/**
* Add values to a rolling window and retrieve percentile calculations such as median, 90th, 99th, etc.
*
* The underlying data structure contains a circular array of buckets that "roll" over time.
*
* For example, if the time window is configured to 60 seconds with 12 buckets of 5 seconds each, values will be captured in each 5 second bucket and rotate each 5 seconds.
*
* This means that percentile calculations are for the "rolling window" of 55-60 seconds up to 5 seconds ago.
*
* Each bucket will contain a circular array of long values and if more than the configured amount (1000 values for example) it will wrap around and overwrite values until time passes and a new bucket
* is allocated. This sampling approach for high volume metrics is done to conserve memory and reduce sorting time when calculating percentiles.
*/
public class HystrixRollingPercentile {
private static final Logger logger = LoggerFactory.getLogger(HystrixRollingPercentile.class);
private static final Time ACTUAL_TIME = new ActualTime();
private final Time time;
/* package for testing */ final BucketCircularArray buckets;
private final int timeInMilliseconds;
private final int numberOfBuckets;
private final int bucketDataLength;
private final int bucketSizeInMilliseconds;
private final HystrixProperty enabled;
/*
* This will get flipped each time a new bucket is created.
*/
/* package for testing */ volatile PercentileSnapshot currentPercentileSnapshot = new PercentileSnapshot(0);
/**
*
* @param timeInMilliseconds
* {@code HystrixProperty} for number of milliseconds of data that should be tracked
* Note that this value is represented as a {@link HystrixProperty}, but can not actually be modified
* at runtime, to avoid data loss
*
* Example: 60000 for 1 minute
* @param numberOfBuckets
* {@code HystrixProperty} for number of buckets that the time window should be divided into
* Note that this value is represented as a {@link HystrixProperty}, but can not actually be modified
* at runtime, to avoid data loss
*
* Example: 12 for 5 second buckets in a 1 minute window
* @param bucketDataLength
* {@code HystrixProperty} for number of values stored in each bucket
* Note that this value is represented as a {@link HystrixProperty}, but can not actually be modified
* at runtime, to avoid data loss
*
* Example: 1000 to store a max of 1000 values in each 5 second bucket
* @param enabled
* {@code HystrixProperty} whether data should be tracked and percentiles calculated.
*
* If 'false' methods will do nothing.
* @deprecated Please use the constructor with non-configurable properties {@link HystrixRollingPercentile(Time, int, int, int, HystrixProperty}
*/
@Deprecated
public HystrixRollingPercentile(HystrixProperty timeInMilliseconds, HystrixProperty numberOfBuckets, HystrixProperty bucketDataLength, HystrixProperty enabled) {
this(timeInMilliseconds.get(), numberOfBuckets.get(), bucketDataLength.get(), enabled);
}
/**
*
* @param timeInMilliseconds
* number of milliseconds of data that should be tracked
*
* Example: 60000 for 1 minute
* @param numberOfBuckets
* number of buckets that the time window should be divided into
*
* Example: 12 for 5 second buckets in a 1 minute window
* @param bucketDataLength
* number of values stored in each bucket
*
* Example: 1000 to store a max of 1000 values in each 5 second bucket
* @param enabled
* {@code HystrixProperty} whether data should be tracked and percentiles calculated.
*
* If 'false' methods will do nothing.
*/
public HystrixRollingPercentile(int timeInMilliseconds, int numberOfBuckets, int bucketDataLength, HystrixProperty enabled) {
this(ACTUAL_TIME, timeInMilliseconds, numberOfBuckets, bucketDataLength, enabled);
}
/* package for testing */ HystrixRollingPercentile(Time time, int timeInMilliseconds, int numberOfBuckets, int bucketDataLength, HystrixProperty enabled) {
this.time = time;
this.timeInMilliseconds = timeInMilliseconds;
this.numberOfBuckets = numberOfBuckets;
this.bucketDataLength = bucketDataLength;
this.enabled = enabled;
if (this.timeInMilliseconds % this.numberOfBuckets != 0) {
throw new IllegalArgumentException("The timeInMilliseconds must divide equally into numberOfBuckets. For example 1000/10 is ok, 1000/11 is not.");
}
this.bucketSizeInMilliseconds = this.timeInMilliseconds / this.numberOfBuckets;
buckets = new BucketCircularArray(this.numberOfBuckets);
}
/**
* Add value (or values) to current bucket.
*
* @param value
* Value to be stored in current bucket such as execution latency in milliseconds
*/
public void addValue(int... value) {
/* no-op if disabled */
if (!enabled.get())
return;
for (int v : value) {
try {
getCurrentBucket().data.addValue(v);
} catch (Exception e) {
logger.error("Failed to add value: " + v, e);
}
}
}
/**
* Compute a percentile from the underlying rolling buckets of values.
*
* For performance reasons it maintains a single snapshot of the sorted values from all buckets that is re-generated each time the bucket rotates.
*
* This means that if a bucket is 5000ms, then this method will re-compute a percentile at most once every 5000ms.
*
* @param percentile
* value such as 99 (99th percentile), 99.5 (99.5th percentile), 50 (median, 50th percentile) to compute and retrieve percentile from rolling buckets.
* @return int percentile value
*/
public int getPercentile(double percentile) {
/* no-op if disabled */
if (!enabled.get())
return -1;
// force logic to move buckets forward in case other requests aren't making it happen
getCurrentBucket();
// fetch the current snapshot
return getCurrentPercentileSnapshot().getPercentile(percentile);
}
/**
* This returns the mean (average) of all values in the current snapshot. This is not a percentile but often desired so captured and exposed here.
*
* @return mean of all values
*/
public int getMean() {
/* no-op if disabled */
if (!enabled.get())
return -1;
// force logic to move buckets forward in case other requests aren't making it happen
getCurrentBucket();
// fetch the current snapshot
return getCurrentPercentileSnapshot().getMean();
}
/**
* This will retrieve the current snapshot or create a new one if one does not exist.
*
* It will NOT include data from the current bucket, but all previous buckets.
*
* It remains cached until the next bucket rotates at which point a new one will be created.
*/
private PercentileSnapshot getCurrentPercentileSnapshot() {
return currentPercentileSnapshot;
}
private ReentrantLock newBucketLock = new ReentrantLock();
private Bucket getCurrentBucket() {
long currentTime = time.getCurrentTimeInMillis();
/* a shortcut to try and get the most common result of immediately finding the current bucket */
/**
* Retrieve the latest bucket if the given time is BEFORE the end of the bucket window, otherwise it returns NULL.
*
* NOTE: This is thread-safe because it's accessing 'buckets' which is a LinkedBlockingDeque
*/
Bucket currentBucket = buckets.peekLast();
if (currentBucket != null && currentTime < currentBucket.windowStart + this.bucketSizeInMilliseconds) {
// if we're within the bucket 'window of time' return the current one
// NOTE: We do not worry if we are BEFORE the window in a weird case of where thread scheduling causes that to occur,
// we'll just use the latest as long as we're not AFTER the window
return currentBucket;
}
/* if we didn't find the current bucket above, then we have to create one */
/**
* The following needs to be synchronized/locked even with a synchronized/thread-safe data structure such as LinkedBlockingDeque because
* the logic involves multiple steps to check existence, create an object then insert the object. The 'check' or 'insertion' themselves
* are thread-safe by themselves but not the aggregate algorithm, thus we put this entire block of logic inside synchronized.
*
* I am using a tryLock if/then (http://download.oracle.com/javase/6/docs/api/java/util/concurrent/locks/Lock.html#tryLock())
* so that a single thread will get the lock and as soon as one thread gets the lock all others will go the 'else' block
* and just return the currentBucket until the newBucket is created. This should allow the throughput to be far higher
* and only slow down 1 thread instead of blocking all of them in each cycle of creating a new bucket based on some testing
* (and it makes sense that it should as well).
*
* This means the timing won't be exact to the millisecond as to what data ends up in a bucket, but that's acceptable.
* It's not critical to have exact precision to the millisecond, as long as it's rolling, if we can instead reduce the impact synchronization.
*
* More importantly though it means that the 'if' block within the lock needs to be careful about what it changes that can still
* be accessed concurrently in the 'else' block since we're not completely synchronizing access.
*
* For example, we can't have a multi-step process to add a bucket, remove a bucket, then update the sum since the 'else' block of code
* can retrieve the sum while this is all happening. The trade-off is that we don't maintain the rolling sum and let readers just iterate
* bucket to calculate the sum themselves. This is an example of favoring write-performance instead of read-performance and how the tryLock
* versus a synchronized block needs to be accommodated.
*/
if (newBucketLock.tryLock()) {
try {
if (buckets.peekLast() == null) {
// the list is empty so create the first bucket
Bucket newBucket = new Bucket(currentTime, bucketDataLength);
buckets.addLast(newBucket);
return newBucket;
} else {
// We go into a loop so that it will create as many buckets as needed to catch up to the current time
// as we want the buckets complete even if we don't have transactions during a period of time.
for (int i = 0; i < numberOfBuckets; i++) {
// we have at least 1 bucket so retrieve it
Bucket lastBucket = buckets.peekLast();
if (currentTime < lastBucket.windowStart + this.bucketSizeInMilliseconds) {
// if we're within the bucket 'window of time' return the current one
// NOTE: We do not worry if we are BEFORE the window in a weird case of where thread scheduling causes that to occur,
// we'll just use the latest as long as we're not AFTER the window
return lastBucket;
} else if (currentTime - (lastBucket.windowStart + this.bucketSizeInMilliseconds) > timeInMilliseconds) {
// the time passed is greater than the entire rolling counter so we want to clear it all and start from scratch
reset();
// recursively call getCurrentBucket which will create a new bucket and return it
return getCurrentBucket();
} else { // we're past the window so we need to create a new bucket
Bucket[] allBuckets = buckets.getArray();
// create a new bucket and add it as the new 'last' (once this is done other threads will start using it on subsequent retrievals)
buckets.addLast(new Bucket(lastBucket.windowStart + this.bucketSizeInMilliseconds, bucketDataLength));
// we created a new bucket so let's re-generate the PercentileSnapshot (not including the new bucket)
currentPercentileSnapshot = new PercentileSnapshot(allBuckets);
}
}
// we have finished the for-loop and created all of the buckets, so return the lastBucket now
return buckets.peekLast();
}
} finally {
newBucketLock.unlock();
}
} else {
currentBucket = buckets.peekLast();
if (currentBucket != null) {
// we didn't get the lock so just return the latest bucket while another thread creates the next one
return currentBucket;
} else {
// the rare scenario where multiple threads raced to create the very first bucket
// wait slightly and then use recursion while the other thread finishes creating a bucket
try {
Thread.sleep(5);
} catch (Exception e) {
// ignore
}
return getCurrentBucket();
}
}
}
/**
* Force a reset so that percentiles start being gathered from scratch.
*/
public void reset() {
/* no-op if disabled */
if (!enabled.get())
return;
// clear buckets so we start over again
buckets.clear();
// and also make sure the percentile snapshot gets reset
currentPercentileSnapshot = new PercentileSnapshot(buckets.getArray());
}
/* package-private for testing */ static class PercentileBucketData {
private final int length;
private final AtomicIntegerArray list;
private final AtomicInteger index = new AtomicInteger();
public PercentileBucketData(int dataLength) {
this.length = dataLength;
this.list = new AtomicIntegerArray(dataLength);
}
public void addValue(int... latency) {
for (int l : latency) {
/* We just wrap around the beginning and over-write if we go past 'dataLength' as that will effectively cause us to "sample" the most recent data */
list.set(index.getAndIncrement() % length, l);
// TODO Alternative to AtomicInteger? The getAndIncrement may be a source of contention on high throughput circuits on large multi-core systems.
// LongAdder isn't suited to this as it is not consistent. Perhaps a different data structure that doesn't need indexed adds?
// A threadlocal data storage that only aggregates when fetched would be ideal. Similar to LongAdder except for accumulating lists of data.
}
}
public int length() {
if (index.get() > list.length()) {
return list.length();
} else {
return index.get();
}
}
}
/**
* @NotThreadSafe
*/
/* package for testing */ static class PercentileSnapshot {
private final int[] data;
private final int length;
private int mean;
/* package for testing */ PercentileSnapshot(Bucket[] buckets) {
int lengthFromBuckets = 0;
// we need to calculate it dynamically as it could have been changed by properties (rare, but possible)
// also this way we capture the actual index size rather than the max so size the int[] to only what we need
for (Bucket bd : buckets) {
lengthFromBuckets += bd.data.length;
}
data = new int[lengthFromBuckets];
int index = 0;
int sum = 0;
for (Bucket bd : buckets) {
PercentileBucketData pbd = bd.data;
int length = pbd.length();
for (int i = 0; i < length; i++) {
int v = pbd.list.get(i);
this.data[index++] = v;
sum += v;
}
}
this.length = index;
if (this.length == 0) {
this.mean = 0;
} else {
this.mean = sum / this.length;
}
Arrays.sort(this.data, 0, length);
}
/* package for testing */ PercentileSnapshot(int... data) {
this.data = data;
this.length = data.length;
int sum = 0;
for (int v : data) {
sum += v;
}
this.mean = sum / this.length;
Arrays.sort(this.data, 0, length);
}
/* package for testing */ int getMean() {
return mean;
}
/**
* Provides percentile computation.
*/
public int getPercentile(double percentile) {
if (length == 0) {
return 0;
}
return computePercentile(percentile);
}
/**
* @see Percentile (Wikipedia)
* @see Percentile
*
* @param percent percentile of data desired
* @return data at the asked-for percentile. Interpolation is used if exactness is not possible
*/
private int computePercentile(double percent) {
// Some just-in-case edge cases
if (length <= 0) {
return 0;
} else if (percent <= 0.0) {
return data[0];
} else if (percent >= 100.0) {
return data[length - 1];
}
// ranking (http://en.wikipedia.org/wiki/Percentile#Alternative_methods)
double rank = (percent / 100.0) * length;
// linear interpolation between closest ranks
int iLow = (int) Math.floor(rank);
int iHigh = (int) Math.ceil(rank);
assert 0 <= iLow && iLow <= rank && rank <= iHigh && iHigh <= length;
assert (iHigh - iLow) <= 1;
if (iHigh >= length) {
// Another edge case
return data[length - 1];
} else if (iLow == iHigh) {
return data[iLow];
} else {
// Interpolate between the two bounding values
return (int) (data[iLow] + (rank - iLow) * (data[iHigh] - data[iLow]));
}
}
}
/**
* This is a circular array acting as a FIFO queue.
*
* It purposefully does NOT implement Deque or some other Collection interface as it only implements functionality necessary for this RollingNumber use case.
*
* Important Thread-Safety Note: This is ONLY thread-safe within the context of RollingNumber and the protection it gives in the getCurrentBucket
method. It uses AtomicReference
* objects to ensure anything done outside of getCurrentBucket
is thread-safe, and to ensure visibility of changes across threads (ie. volatility) but the addLast and removeFirst
* methods are NOT thread-safe for external access they depend upon the lock.tryLock() protection in getCurrentBucket
which ensures only a single thread will access them at at time.
*
* benjchristensen => This implementation was chosen based on performance testing I did and documented at: http://benjchristensen.com/2011/10/08/atomiccirculararray/
*/
/* package for testing */ static class BucketCircularArray implements Iterable {
private final AtomicReference state;
private final int dataLength; // we don't resize, we always stay the same, so remember this
private final int numBuckets;
/**
* Immutable object that is atomically set every time the state of the BucketCircularArray changes
*
* This handles the compound operations
*/
private class ListState {
/*
* this is an AtomicReferenceArray and not a normal Array because we're copying the reference
* between ListState objects and multiple threads could maintain references across these
* compound operations so I want the visibility/concurrency guarantees
*/
private final AtomicReferenceArray data;
private final int size;
private final int tail;
private final int head;
private ListState(AtomicReferenceArray data, int head, int tail) {
this.head = head;
this.tail = tail;
if (head == 0 && tail == 0) {
size = 0;
} else {
this.size = (tail + dataLength - head) % dataLength;
}
this.data = data;
}
public Bucket tail() {
if (size == 0) {
return null;
} else {
// we want to get the last item, so size()-1
return data.get(convert(size - 1));
}
}
private Bucket[] getArray() {
/*
* this isn't technically thread-safe since it requires multiple reads on something that can change
* but since we never clear the data directly, only increment/decrement head/tail we would never get a NULL
* just potentially return stale data which we are okay with doing
*/
ArrayList array = new ArrayList();
for (int i = 0; i < size; i++) {
array.add(data.get(convert(i)));
}
return array.toArray(new Bucket[array.size()]);
}
private ListState incrementTail() {
/* if incrementing results in growing larger than 'length' which is the max we should be at, then also increment head (equivalent of removeFirst but done atomically) */
if (size == numBuckets) {
// increment tail and head
return new ListState(data, (head + 1) % dataLength, (tail + 1) % dataLength);
} else {
// increment only tail
return new ListState(data, head, (tail + 1) % dataLength);
}
}
public ListState clear() {
return new ListState(new AtomicReferenceArray(dataLength), 0, 0);
}
public ListState addBucket(Bucket b) {
/*
* We could in theory have 2 threads addBucket concurrently and this compound operation would interleave.
*
* This should NOT happen since getCurrentBucket is supposed to be executed by a single thread.
*
* If it does happen, it's not a huge deal as incrementTail() will be protected by compareAndSet and one of the two addBucket calls will succeed with one of the Buckets.
*
* In either case, a single Bucket will be returned as "last" and data loss should not occur and everything keeps in sync for head/tail.
*
* Also, it's fine to set it before incrementTail because nothing else should be referencing that index position until incrementTail occurs.
*/
data.set(tail, b);
return incrementTail();
}
// The convert() method takes a logical index (as if head was
// always 0) and calculates the index within elementData
private int convert(int index) {
return (index + head) % dataLength;
}
}
BucketCircularArray(int size) {
AtomicReferenceArray _buckets = new AtomicReferenceArray(size + 1); // + 1 as extra room for the add/remove;
state = new AtomicReference(new ListState(_buckets, 0, 0));
dataLength = _buckets.length();
numBuckets = size;
}
public void clear() {
while (true) {
/*
* it should be very hard to not succeed the first pass thru since this is typically is only called from
* a single thread protected by a tryLock, but there is at least 1 other place (at time of writing this comment)
* where reset can be called from (CircuitBreaker.markSuccess after circuit was tripped) so it can
* in an edge-case conflict.
*
* Instead of trying to determine if someone already successfully called clear() and we should skip
* we will have both calls reset the circuit, even if that means losing data added in between the two
* depending on thread scheduling.
*
* The rare scenario in which that would occur, we'll accept the possible data loss while clearing it
* since the code has stated its desire to clear() anyways.
*/
ListState current = state.get();
ListState newState = current.clear();
if (state.compareAndSet(current, newState)) {
return;
}
}
}
/**
* Returns an iterator on a copy of the internal array so that the iterator won't fail by buckets being added/removed concurrently.
*/
public Iterator iterator() {
return Collections.unmodifiableList(Arrays.asList(getArray())).iterator();
}
public void addLast(Bucket o) {
ListState currentState = state.get();
// create new version of state (what we want it to become)
ListState newState = currentState.addBucket(o);
/*
* use compareAndSet to set in case multiple threads are attempting (which shouldn't be the case because since addLast will ONLY be called by a single thread at a time due to protection
* provided in getCurrentBucket
)
*/
if (state.compareAndSet(currentState, newState)) {
// we succeeded
return;
} else {
// we failed, someone else was adding or removing
// instead of trying again and risking multiple addLast concurrently (which shouldn't be the case)
// we'll just return and let the other thread 'win' and if the timing is off the next call to getCurrentBucket will fix things
return;
}
}
public int size() {
// the size can also be worked out each time as:
// return (tail + data.length() - head) % data.length();
return state.get().size;
}
public Bucket peekLast() {
return state.get().tail();
}
private Bucket[] getArray() {
return state.get().getArray();
}
}
/**
* Counters for a given 'bucket' of time.
*/
/* package for testing */ static class Bucket {
final long windowStart;
final PercentileBucketData data;
Bucket(long startTime, int bucketDataLength) {
this.windowStart = startTime;
this.data = new PercentileBucketData(bucketDataLength);
}
}
/* package for testing */ static interface Time {
public long getCurrentTimeInMillis();
}
private static class ActualTime implements Time {
@Override
public long getCurrentTimeInMillis() {
return System.currentTimeMillis();
}
}
}