掘金 后端 ( ) • 2024-04-07 10:18

topK问题是一个非常实用的问题,也是面试官最爱问的问题之一。常常用于比如热门话题标签(社交媒体平台、新闻发布网络)topk可以回答以下问题:

  • 在过去的 X 小时内,人们提到最多的 K 标签是什么?
  • 今天阅读/浏览次数最高的 K 新闻是什么?

检测网络异常和 DDoS 攻击,Top K 可以回答以下问题:

  • 来自同一地址或同一 IP 的请求流量是否突然增加?

使用排序(基础方案)

如果数据能够加载进内存,先用Map或者字典结构对数据进行访问量统计,然后进行排序,使用常见的排序算法,比如快排。或者使用快排思想以O(logN)时间复杂度找到第K大,然后前面的数就是topK,这个算法很简单,leetcode上有原题。

    private void quikSort(int[] nums, int left, int right, int index) {
        if (right < left || found) {
            return;
        }
        int base = nums[left];
        int i = left;
        int j = right;
        while (i < j) {
            while (i < j && nums[j] >= base) {
                j--;
            }
            while (i < j && nums[i] <= base) {
                i++;
            }
            int tmp = nums[i];
            nums[i] = nums[j];
            nums[j] = tmp;
        }
        nums[left] = nums[i];
        nums[i] = base;
        if (index == i) {
            ans = base;
            found = true;
            return;
        }
        if (index < i) {
            quikSort(nums, left, i - 1, index);
        } else {
            quikSort(nums, i + 1, right, index);
        }
    }

在分布式系统中使用小顶堆(进阶)

如果数据量非常大,无法把全量数据在内存中计数并排序,我们可以采用分布式的思想,假如数据流是通过Kafka等流式处理平台投递的,可以部署m个服务对订阅数据流,把每个key的计数结果累积然后存到redis中,使用一个微服务保存一个小顶堆,堆大小为k,如果某个key的计数比小顶堆对顶元素小,则弹出堆顶元素,把新的key入堆。

如果不使用Redis,每个计数服务也维护一个自己的TopK,然后由一个topk服务对各个计数的topk服务进行聚合(归并思想)。

使用概率数据结构(终极)

如果数据量再大呢,如果一味横向拓展计数微服务和纵向扩展内存可能不太能接受,但是我们可以牺牲一点精确性,但是得到差不多的效果,此时我们可以考虑一种算法,叫HeavyKeeper。HeavyKeeper聚焦于寻找top-k元素的应用场景,采用一种指数衰减计数策略(count-with-exponential-decay)将小流计数衰减为零从而为大流腾出空间,实现寻找top-k的目的。

找top-k大象流是网络流量测量中的关键任务,在拥塞控制(congestion control)、异常检测(anomaly detection)和流量工程(traffic engineering)中有着广泛的应用。随着网络中线路速率的不断提高,设计准确、快速的大象流在线识别算法变得越来越具有挑战性以前算法限制:在大的流量(heavy traffic)和小的片上存储器的约束下,现有的算法在实现精度方面受到严重限制。这些算法所采用的基本策略要么需要大量的空间开销来测量所有流的大小,要么在决定跟踪哪些流时产生显著的不准确性。我们的策略:我们采用了一种新的策略,称为指数衰减计数(count-with-exponential-decay),通过衰减主动去除小流量(small flows)来实现空间精度平衡,同时最小化对大流量(large flows)的影响,从而实现查找top-k大象流的高精度。此外,所提出的称为HeavyKeeper的算法对每个数据包的处理开销很小,且恒定,因此支持高线路速率(high line rates)。实验效果:HeavyKeeper算法在较小的内存容量下,准确率达到99.99%,与现有算法相比,误差平均降低了3个数量级左右。

找top-k的两种策略:寻找top-k流量的传统解决方案遵循两种基本策略:计数所有(count-all)和承认所有计数一些(admit-all-count-some)。count-all策略依赖于一个sketch(例如CM sketch)来测量所有流的大小,同时使用最小堆(min-heap)来跟踪top-k流。对于每个传入的数据包,它将数据包记录在sketch中,并从sketch中检索数据包所属流大小的估计值 。由于需要一个大的sketch来计算所有流,这些解决方案的内存效率不高(not memory efficient)。

承认所有计数一些的策略(admit-all-count-some) 被Frequent、Lossy Counting、Space Saving和 CSS所采用。这些算法彼此相似。Space Saving 方法:为了节省内存,Space-Saving只维护一个名为Stream-Summary的数据结构,只计数一些流(例如m个流)。每个新流都将插入摘要(Summary)中,替换最小的现有流。通过在Summary中保留m个流,算法将报告其中最大的k个流,其中m>k。它假设每个新传入的流都是大象流,并在Summary中排除最小的流,为新流腾出空间。

HeavyKeeper,它使用了HeavyGuardian 中引入的类似策略,称为count-with-exponential-decay。与count-all不同,我们的策略只跟踪一小部分流量。与admit-all-count-some不同,我们不会自动承认新的流进入我们的数据结构,并且绝大多数鼠标流将被绕过。指数衰减思想:对于少数确实进入我们数据结构的鼠标流,它们将逐渐衰减,为真正的大象流腾出空间。在我们的数据结构中,流的衰减是不一致的(not uniform)。指数衰减(exponential decay) 的设计偏向于小流量,对大流量的影响较小。这种设计在小内存下可以很好地处理真实的流量轨迹。

这个论文可以参考:atc18-gong.pdf (usenix.org)

好在我们不用自己去实现它,Redis有现成的工具帮助我们完成这个任务,使用很小的内存完成寻找海量数据流topk!****

使用命令:

TOPK.RESERVE key k width depth decay_constant

必传参数

  • key: Key under which the sketch is to be found.
  • topk: Number of top occurring items to keep.

可选参数

  • width: Number of counters kept in each array. (Default 8)
  • depth: Number of arrays. (Default 7)
  • decay: The probability of reducing a counter in an occupied bucket. It is raised to power of it's counter (decay ^ bucket[i].counter). Therefore, as the counter gets higher, the chance of a reduction is being reduced. (Default 0.9)