硕大的汤姆

硕大的汤姆

The official website of Minhua Chen

04 Sep 2019

P99与蓄水池算法(reservoir sampling)

一个监控问题

假设你是一个运维工作人员,维护着一个访问量巨大的服务,然后有一天,老板跑来问你这个服务的 p99 响应时间是多少?(p99 响应时间:系统 99%的请求都快于这个时间,而 1%的请求则慢于这个时间。即响应时间的 99% 分位点。)

一个简单的算法是直接对所有请求的访问时间进行排序,然后取出 99%位置的请求的访问时间。但是由于系统访问量巨大,每天都有上亿的请求,而一般的排序算法的时间复杂度为 O(n * log(n)),在这样的数据量级上,算法的空间复杂度也很高。怎么优化这个算法呢?

抽样

上亿的数据实在太多了,但是假设我们的系统是相对稳定的,则所有数据应该是同分布的,那我们应该可以通过随机采样的方式,选出一部分样本来,用这些样本来描述总体。比如说我知道今天会有一亿个请求,然后我就可以按千分之一来进行数据抽样,即每个数据都按照千分之一的概率抽取到样本集中。

这里就遇到另一个关键性的问题,我不知道今天会有多少请求,即我不知道总体有多大,更讨厌的是,总体在不断变大,那样本集也会不断变大,排序会越来越困难。

这时候,你应该打开 John Bentley 所著的《编程珠玑》,翻到第十二章,那里有这个问题的答案。

答案就是蓄水池算法,具体可参考《编程珠玑》或 wiki。https://en.wikipedia.org/wiki/Reservoir_sampling , 算法如下。

    for (long i = 0; i < LOOP_NUM; i++) {
        if (i < SAMPLE_NUM) {
            samples.push_back(n(e));
        } else {
            long r = randint(0, i);
            float ne = n(e);
            if (r < SAMPLE_NUM) {
                samples[r] = ne;
            }
        }
    }

注意,这个 samples 数组是动态更新的,每个新样本进来都会被随机选择是否替换进来。

排序

回到上面的 p99 问题,我们可以设定程序每隔一段时间对样本数组进行排序,并选择 99%位置的数字。时间复杂度就是 O(m * log(m)),空间复杂度为 O(m),其中 m 为样本集大小。

但是,我们还有更好的方法。这显然是一个 Top k 问题,看到 Top k 就想到堆嘛。

我们可以维护一个容量为 k 的最小堆(这里 k = 0.1 * m),然后把元素一个一个加入堆,当堆满了之后,只有比堆顶元素大的元素才能加入堆,并顶掉原来的堆顶元素。最后在堆顶的元素就是 p99 的值啦。这样的方法在时间和空间上都能省不少。(另外,如果你想知道最快的 1%的话,就要用最大堆了。)

    //最小堆
    int q1_cap = (int) (SAMPLE_NUM * 0.01);
    for (int i = 0; i < SAMPLE_NUM; i++) {
        if (i < q1_cap) {
            q1.push(v[i]);
        } else {
            if (v[i] > q1.top()) {
                q1.push(v[i]);
                q1.pop();
            }
        }
    }

Put it together

我们做个实验,实验数据不是系统延时,而是一个正态分布样本生成器。我们用样本生成器生成一亿条数据,并按千分之一进行采样,然后利用堆计算其 p99(其实就是 1%分位点。)

#define LOOP_NUM 100000000
#define SAMPLE_NUM 100000
#define randint(min, max) (rand() % (max - min + 1) + min)

int main() {
priority_queue<float, std::vector<float>, std::greater<float>> q1; // 最小堆
std::normal_distribution<float> n(0, 1); //均值为 0, 方差为 1
std::default_random_engine e; //random engine
std::vector<float> v;

    clock_t t1 = clock();
    // reservoir sampling
    for (long i = 0; i < LOOP_NUM; i++) {
        if (i < SAMPLE_NUM) {
            v.push_back(n(e));
        } else {
            long r = randint(0, i);
            float ne = n(e);
            if (r < SAMPLE_NUM) {
                v[r] = ne;
            }
        }
    }

    clock_t t2 = clock();

    //最小堆
    int q1_cap = (int) (SAMPLE_NUM * 0.01);
    for (int i = 0; i < SAMPLE_NUM; i++) {
        if (i < q1_cap) {
            q1.push(v[i]);
        } else {
            if (v[i] > q1.top()) {
                q1.push(v[i]);
                q1.pop();
            }
        }
    }
    clock_t t3 = clock();
    std::cout << q1.top() << std::endl;
    std::cout << "sampling time " << (t2 - t1) / 1000000. << std::endl;
    std::cout << "heap time " << (t3 - t2) / 1000000. << std::endl;
    return 0;

}

最终结果为 2.32081。堆化非常快,只用了 2.5ms。我们知道标准正态分布的上 0.01 分位点是 2.327,已经非常接近了。

同时请注意,我们的采样算法不需要知道总体样本数,也就是说,我们继续向其中加入一亿数据,应该还是非常接近 2.327 的。我们试试在上面的 return 前加入如下代码:

    ....
    for (long i = 0; i < LOOP_NUM; i++) {
        float ne = n(e);
        if (i < SAMPLE_NUM) {
            v.push_back(ne);
        } else {
            long r = randint(0, i + LOOP_NUM);
            if (r < SAMPLE_NUM) {
                v[r] = ne;
            }
        }
    }
    for (int i = 0; i < SAMPLE_NUM; i++) {
        if (i < q1_cap) {
            q1.push(v[i]);
        } else {
            if (v[i] > q1.top()) {
                q1.push(v[i]);
                q1.pop();
            }
        }
    }
    std::cout << q1.top() << std::endl;

    return 0;
}

估算出来的 p99 为 2.32563,离 2.327 又近了一步。

PS: 代码可以在我的 github 找到,下面这个仓库的 sample 目录下 github.com/chenminhua/math​